244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { CheckCircle2, Copy, Megaphone, Plus, Send, X } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
|
|
type InstantMeetingPerson = {
|
|
id: string;
|
|
name: string;
|
|
emailRecipients: string[];
|
|
calendarIds: string[];
|
|
};
|
|
|
|
type CacheEntry = { email: string; name: string; lastUsedAt: string };
|
|
|
|
type BootstrapResponse = {
|
|
people: InstantMeetingPerson[];
|
|
emailCache: CacheEntry[];
|
|
defaultSubject: string;
|
|
defaultTemplate: string;
|
|
};
|
|
|
|
type MeetingResult = {
|
|
sentCount: number;
|
|
scopeLabel: string;
|
|
meetingUrl: string;
|
|
};
|
|
|
|
export function InstantMeetingPanel() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [sending, setSending] = useState(false);
|
|
|
|
const [people, setPeople] = useState<InstantMeetingPerson[]>([]);
|
|
const [emailCache, setEmailCache] = useState<CacheEntry[]>([]);
|
|
const [personScopeId, setPersonScopeId] = useState("all");
|
|
const [subjectOverride, setSubjectOverride] = useState("");
|
|
const [customMessage, setCustomMessage] = useState("");
|
|
const [cacheInput, setCacheInput] = useState("");
|
|
const [additionalRecipients, setAdditionalRecipients] = useState<Array<{ email: string; name: string }>>([]);
|
|
const [result, setResult] = useState<MeetingResult | null>(null);
|
|
|
|
const allRecipients = useMemo(() => additionalRecipients, [additionalRecipients]);
|
|
|
|
function addAdditionalEmail(raw: string, nameOverride?: string) {
|
|
const normalized = (raw ?? "").trim();
|
|
if (!normalized || !normalized.includes("@")) {
|
|
toast.error("Bitte eine gültige E-Mail-Adresse eingeben.");
|
|
return;
|
|
}
|
|
if (allRecipients.some((r) => r.email.toLowerCase() === normalized.toLowerCase())) {
|
|
toast.error("Adresse bereits ausgewählt.");
|
|
return;
|
|
}
|
|
const name = nameOverride?.trim() || normalized.split("@")[0] || "";
|
|
setAdditionalRecipients((prev) => [...prev, { email: normalized, name }]);
|
|
setCacheInput("");
|
|
}
|
|
|
|
function removeAdditionalEmail(email: string) {
|
|
setAdditionalRecipients((prev) => prev.filter((r) => r.email !== email));
|
|
}
|
|
|
|
const bootstrap = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/admin/instant-meeting", { cache: "no-store" });
|
|
const data = (await res.json()) as BootstrapResponse & { message?: string };
|
|
if (!res.ok) { toast.error(data?.message ?? "Konnte Daten nicht laden."); return; }
|
|
setPeople(data.people ?? []);
|
|
setEmailCache(data.emailCache ?? []);
|
|
setSubjectOverride(data.defaultSubject ?? "");
|
|
} catch { toast.error("Konnte Daten nicht laden."); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => { void bootstrap(); }, []);
|
|
|
|
const onSendMeeting = async () => {
|
|
setSending(true);
|
|
try {
|
|
const res = await fetch("/api/admin/instant-meeting", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
personScopeId,
|
|
subjectOverride: subjectOverride.trim() || undefined,
|
|
customMessage: customMessage.trim() || undefined,
|
|
additionalRecipients: additionalRecipients.map((r) => r.email)
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { toast.error(data?.message ?? "Versand fehlgeschlagen."); return; }
|
|
setResult({
|
|
sentCount: data.sentCount ?? 0,
|
|
scopeLabel: data.scopeLabel ?? "",
|
|
meetingUrl: data.meetingUrl ?? ""
|
|
});
|
|
setAdditionalRecipients([]);
|
|
toast.success("Instant Meeting gesendet.");
|
|
} catch { toast.error("Versand fehlgeschlagen."); }
|
|
finally { setSending(false); }
|
|
};
|
|
|
|
if (loading) return null;
|
|
|
|
const quickCache = emailCache.slice(0, 12);
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-black tracking-tight text-slate-950">Instant Meeting</h1>
|
|
<p className="mt-1 text-sm font-medium text-slate-500">Spontanen Meeting-Link per E-Mail senden</p>
|
|
</div>
|
|
|
|
{/* Config section */}
|
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
|
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
|
<Megaphone className="h-4 w-4 text-slate-400" />
|
|
<h2 className="text-sm font-black text-slate-900">Konfiguration</h2>
|
|
</div>
|
|
<div className="p-5 space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="im-person">Person</Label>
|
|
<select
|
|
id="im-person"
|
|
value={personScopeId}
|
|
onChange={(e) => setPersonScopeId(e.target.value)}
|
|
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-900 transition-all focus:border-indigo-600 focus:outline-none focus:ring-1 focus:ring-indigo-600"
|
|
>
|
|
<option value="all">Alle / Beide Personen</option>
|
|
{people.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="im-subject">Mail-Betreff</Label>
|
|
<Input id="im-subject" value={subjectOverride} onChange={(e) => setSubjectOverride(e.target.value)} placeholder="Sofort-Meeting" />
|
|
<p className="text-xs text-slate-400">{"Platzhalter: {{companyName}}, {{recipientName}}, {{meetingUrl}}"}</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="im-message">Zusatznachricht (optional)</Label>
|
|
<Textarea id="im-message" rows={3} value={customMessage} onChange={(e) => setCustomMessage(e.target.value)} placeholder="Optionaler Zusatztext..." />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recipients section */}
|
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
|
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
|
<Megaphone className="h-4 w-4 text-slate-400" />
|
|
<h2 className="text-sm font-black text-slate-900">Empfänger</h2>
|
|
</div>
|
|
<div className="p-5 space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="im-email">Zusätzliche Empfänger</Label>
|
|
<div className="flex gap-2">
|
|
<Input id="im-email" list="im-cache" value={cacheInput} onChange={(e) => setCacheInput(e.target.value)} placeholder="name@example.com" />
|
|
<Button type="button" variant="secondary" onClick={() => addAdditionalEmail(cacheInput)}>
|
|
<Plus className="mr-1.5 h-4 w-4" /> Hinzufügen
|
|
</Button>
|
|
</div>
|
|
<datalist id="im-cache">
|
|
{emailCache.map((e) => <option key={e.email} value={e.email}>{e.name || e.email}</option>)}
|
|
</datalist>
|
|
</div>
|
|
|
|
{quickCache.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-slate-400">Letzte Kontakte</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{quickCache.map((entry) => (
|
|
<button
|
|
key={entry.email}
|
|
type="button"
|
|
onClick={() => addAdditionalEmail(entry.email, entry.name)}
|
|
className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 hover:border-indigo-400 hover:text-indigo-600 transition"
|
|
>
|
|
{entry.name ? `${entry.name} · ${entry.email}` : entry.email}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{additionalRecipients.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-slate-400">Ausgewählt</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{additionalRecipients.map((entry) => (
|
|
<span key={entry.email} className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 pl-3 pr-1.5 py-1 text-xs font-bold text-slate-600">
|
|
{entry.name ? `${entry.name} · ${entry.email}` : entry.email}
|
|
<button type="button" onClick={() => removeAdditionalEmail(entry.email)} className="rounded-full p-0.5 hover:bg-red-100 hover:text-red-600">
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm flex items-center justify-between">
|
|
<p><span className="font-black text-slate-900">{allRecipients.length}</span> <span className="text-slate-500">Empfänger gesamt</span></p>
|
|
</div>
|
|
|
|
<Button type="button" onClick={() => { void onSendMeeting(); }} disabled={sending || allRecipients.length === 0} className="w-full md:w-auto">
|
|
<Send className="mr-2 h-4 w-4" />
|
|
{sending ? "Wird versendet..." : "Instant Meeting jetzt senden"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Result */}
|
|
{result && (
|
|
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 shadow-sm overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="px-5 py-4 space-y-3">
|
|
<div className="flex items-center gap-2 text-emerald-800">
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
<p className="font-bold">Versendet an {result.sentCount} Empfänger ({result.scopeLabel})</p>
|
|
</div>
|
|
<div className="rounded-xl border border-emerald-200 bg-white p-3">
|
|
<p className="text-xs text-slate-500 mb-1">Meeting-Link</p>
|
|
<p className="text-sm font-medium text-slate-900 break-all">{result.meetingUrl}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" onClick={async () => { await navigator.clipboard.writeText(result.meetingUrl); toast.success("Meeting-Link kopiert."); }}>
|
|
<Copy className="mr-2 h-4 w-4" /> Link kopieren
|
|
</Button>
|
|
<Button type="button" onClick={() => window.open(result.meetingUrl, "_blank", "noopener,noreferrer")}>
|
|
Meeting öffnen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|