Files
votianng/frontend/src/Messages.tsx
T
Sven 363bc27c26 Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen
Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
2026-10-13 20:31:31 +02:00

86 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type FormEvent } from "react";
import { LoaderCircle, MessageSquare, Radio, Plus, Send } from "lucide-react";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { request, post, put } from "./api";
import { Newsletters } from "./Newsletters";
type Option={id:number;label:string;headquarters?:number;device?:string;available?:string};
type Options={newsletters?:boolean;devices:boolean;bulletins:boolean;manageGroups:boolean;allActive:boolean;today:string;headquartersId:number;headquarters:Option[];employees:Option[];maxBulletin:number;maxMessage:number};
type Group={id:number;name:string;groups:number[];revision:string};
type Member=Option&{member:boolean;revision:string};
type Bulletin={id:number;created:string;from:string;to:string;text:string;sender:string;senderId:number;recipient:string;headquarters:string;removable:boolean;revision:string};
type Message={id:number;created:string;delivery:string|null;acknowledged:string|null;state:number;priority:number;sender:string;recipient:string;reader:string;answerer:string;subject:string;body:string;replyTo:number;canRead:boolean;canReply:boolean;revision:string};
type Preview={token:string;recipients:Option[];subject:string;body:string;replyTo:number};
type SendResult={queued:number;total:number;uncertain:number;unattempted:number;warning:string};
type Page<T>={total:number;items:T[]};
const when=(v:string|null)=>v?new Date(v).toLocaleString("de-DE"):"–";
const states=["Noch nicht ausgeliefert","Ausgeliefert, ungelesen","Gelesen","Beantwortet"];
function ErrorMessage({error}:{error:string}){return error?<div className="alert error" role="alert">{error}</div>:null;}
function Pages({page,total,onChange,busy=false}:{page:number;total:number;onChange:(page:number)=>void;busy?:boolean}){return <div className="admin-toolbar"><span>{total} Einträge · Seite {page+1} von {Math.max(1,Math.ceil(total/100))}</span><button className="button" disabled={!page||busy} onClick={()=>onChange(page-1)}>Zurück</button><button className="button" disabled={(page+1)*100>=total||busy} onClick={()=>onChange(page+1)}>Weiter</button></div>;}
export function Messages({refresh,onDirtyChange}:{refresh:number;onDirtyChange:(dirty:boolean)=>void}){
const [options,setOptions]=useState<Options|null>(null),[error,setError]=useState(""),[view,setView]=useState(""),[dirty,setDirty]=useState(false);
const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{let live=true;request<Options>("/messages/options").then(o=>{if(live){setOptions(o);setError("");}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[refresh]);
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
const active=view||(options?.devices?"devices":options?.bulletins?"bulletins":"newsletters");
return <><ErrorMessage error={error}/>{options?<div className="administration"><aside className="admin-navigation" aria-label="Nachrichtenbereiche">{options.devices&&<button className={active==="devices"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherte Nachricht verwerfen?"))setView("devices");}}><Radio size={18}/>Endgeräte</button>}{options.bulletins&&<button className={active==="bulletins"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherte Nachricht verwerfen?"))setView("bulletins");}}><MessageSquare size={18}/>Mitteilungen</button>}{options.newsletters&&<button className={active==="newsletters"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherten Newsletter verwerfen?"))setView("newsletters");}}><MessageSquare size={18}/>Newsletter</button>}</aside><section className="panel admin-panel" key={`${active}:${refresh}`}>{active==="devices"?<DeviceMessages options={options} onDirty={setDirty}/>:active==="bulletins"?<Bulletins options={options} onDirty={setDirty}/>:<Newsletters onDirty={setDirty}/>}</section></div>:!error&&<LoaderCircle className="spin"/>}{dialog}</>;
}
function Bulletins({options,onDirty}:{options:Options;onDirty:(v:boolean)=>void}){
const [data,setData]=useState<Page<Bulletin>>({items:[],total:0}),[page,setPage]=useState(0),[expired,setExpired]=useState(false),[reload,setReload]=useState(0),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[editor,setEditor]=useState<{employee:number}|null>(null),[removing,setRemoving]=useState<Bulletin|null>(null);
useEffect(()=>{let live=true;setBusy(true);request<Page<Bulletin>>(`/messages/bulletins?expired=${expired}&page=${page}`).then(d=>{if(live){setData(d);setError("");}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[expired,page,reload]);
async function remove(){if(!removing)return;setBusy(true);try{await request(`/messages/bulletins/${removing.id}`,{method:"DELETE",body:JSON.stringify({revision:removing.revision})});setRemoving(null);setReload(v=>v+1);setNotice("Mitteilung entfernt.");}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><div className="admin-title"><div><h2>Mitteilungen</h2><p>Nachrichten an Mitarbeiter und Niederlassungen mit Gültigkeitszeitraum.</p></div><button className="button primary" onClick={()=>setEditor({employee:0})}><Plus size={16}/>Neue Mitteilung</button></div><ErrorMessage error={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}<label className="admin-check"><input type="checkbox" checked={expired} onChange={e=>{setExpired(e.target.checked);setPage(0);}}/>Abgelaufene Mitteilungen anzeigen</label>{busy&&<LoaderCircle className="spin"/>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Gültigkeit</th><th>Absender / Empfänger</th><th>Mitteilung</th><th>Aktionen</th></tr></thead><tbody>{data.items.map(b=><tr key={b.id}><td>{when(b.from)}<br/>bis {when(b.to)}<small>Erstellt: {when(b.created)}</small></td><td>{b.sender||"Unbekannt"}<br/>An: {b.recipient||b.headquarters||"Alle"}</td><td className="message-text">{b.text}</td><td><button className="button" disabled={!options.employees.some(e=>e.id===b.senderId)} onClick={()=>setEditor({employee:b.senderId})}>Antworten</button>{b.removable&&<button className="button danger" onClick={()=>setRemoving(b)}>Entfernen</button>}</td></tr>)}</tbody></table>{!busy&&!data.items.length&&<p>Keine Mitteilungen vorhanden.</p>}</div><Pages page={page} total={data.total} onChange={setPage} busy={busy}/>
{editor&&<BulletinEditor options={options} employee={editor.employee} onDirty={onDirty} onClose={()=>setEditor(null)} onSaved={()=>{setEditor(null);setReload(v=>v+1);setNotice("Mitteilung veröffentlicht.");}}/>}
{removing&&<Modal title="Mitteilung entfernen" onClose={()=>{if(!busy)setRemoving(null);}}><div className="admin-dialog-body"><p>Diese Mitteilung dauerhaft entfernen?</p><p className="message-text">{removing.text}</p><ErrorMessage error={error}/><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Entfernen</button></div></div></Modal>}</>;
}
function BulletinEditor({options,employee,onDirty,onClose,onSaved}:{options:Options;employee:number;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const [text,setText]=useState(""),[from,setFrom]=useState(options.today),[to,setTo]=useState(options.today),[hq,setHq]=useState(options.headquartersId),[recipient,setRecipient]=useState(employee),[error,setError]=useState(""),[busy,setBusy]=useState(false),[preview,setPreview]=useState(false);const [discard,dialog]=useDiscardConfirmation();
const dirty=!!text||from!==options.today||to!==options.today||hq!==options.headquartersId||recipient!==employee;
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Mitteilung verwerfen?")))onClose();}
async function send(){setBusy(true);setError("");try{await post("/messages/bulletins",{from,to,headquarters:hq,employee:recipient,text});onSaved();}catch(e){setError((e as Error).message);setPreview(false);}finally{setBusy(false);}}
return <><Modal title={employee?"Auf Mitteilung antworten":"Neue Mitteilung"} onClose={close} wide><form className="admin-dialog-body" onSubmit={e=>{e.preventDefault();setPreview(true);}}><ErrorMessage error={error}/><fieldset disabled={busy||preview}><div className="admin-toolbar"><Field label="Gültig von"><input type="date" required value={from} onInput={e=>setFrom(e.currentTarget.value)}/></Field><Field label="Gültig bis"><input type="date" required min={from>options.today?from:options.today} value={to} onInput={e=>setTo(e.currentTarget.value)}/></Field><Field label="Niederlassung"><select value={hq} disabled={recipient>0} onChange={e=>setHq(Number(e.target.value))}>{options.headquarters.length>1&&<option value={0}>Alle freigegebenen Niederlassungen</option>}{options.headquarters.map(h=><option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><Field label="Empfänger"><select value={recipient} onChange={e=>setRecipient(Number(e.target.value))}><option value={0}>Alle Mitarbeiter der Niederlassung</option>{options.employees.map(p=><option key={p.id} value={p.id}>{p.label} [{options.headquarters.find(h=>h.id===p.headquarters)?.label}]</option>)}</select></Field></div><Field label="Mitteilung"><textarea required rows={6} maxLength={options.maxBulletin} value={text} onChange={e=>setText(e.target.value)}/></Field><p className="form-hint">{text.length} / {options.maxBulletin} Zeichen</p><div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Mitteilung prüfen</button></div></fieldset></form></Modal>{preview&&<Modal title="Mitteilung veröffentlichen" onClose={()=>{if(!busy)setPreview(false);}}><div className="admin-dialog-body"><p><strong>An:</strong> {recipient?options.employees.find(e=>e.id===recipient)?.label:hq?options.headquarters.find(h=>h.id===hq)?.label:"Alle freigegebenen Niederlassungen"}</p><p>{from} bis {to}</p><p className="message-text">{text}</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setPreview(false)}>Zurück</button><button className="button primary" disabled={busy} onClick={()=>void send()}>Jetzt veröffentlichen</button></div></div></Modal>}{dialog}</>;
}
function DeviceMessages({options,onDirty}:{options:Options;onDirty:(v:boolean)=>void}){
const initial={from:options.today,to:options.today,states:[0,1],maxPriority:3,direction:"EXCLUDE_HQ",courier:null as number|null,page:0};
const [filter,setFilter]=useState(initial),[query,setQuery]=useState(initial),[data,setData]=useState<Page<Message>>({items:[],total:0}),[groups,setGroups]=useState<Group[]>([]),[reload,setReload]=useState(0),[error,setError]=useState(""),[busy,setBusy]=useState(false),[composer,setComposer]=useState<Message|"new"|null>(null),[groupEditor,setGroupEditor]=useState<Group|"new"|null>(null),[members,setMembers]=useState<Group|null>(null),[removing,setRemoving]=useState<Group|null>(null),[courier,setCourier]=useState<Option|null>(null);
useEffect(()=>{let live=true;setBusy(true);Promise.all([post<Page<Message>>("/messages/history",query),request<Group[]>("/messages/groups")]).then(([d,g])=>{if(live){setData(d);setGroups(g);setError("");}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[query,reload]);
async function read(m:Message){setBusy(true);try{await post(`/messages/${m.id}/read`,{revision:m.revision});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function remove(){if(!removing)return;setBusy(true);try{await request(`/messages/groups/${removing.id}`,{method:"DELETE",body:JSON.stringify({revision:removing.revision})});setRemoving(null);setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><div className="admin-title"><div><h2>Endgeräte und Nachrichten</h2><p>Nachrichtenhistorie, Empfangsbestätigungen und Versand an Transporteure.</p></div><button className="button primary" onClick={()=>setComposer("new")}><Send size={16}/>Neue Nachricht</button></div><ErrorMessage error={error}/>
<form onSubmit={e=>{e.preventDefault();setQuery({...filter,courier:courier?.id||null,page:0});}}><div className="admin-toolbar"><Field label="Von"><input type="date" required value={filter.from} onInput={e=>{const value=e.currentTarget.value;setFilter(v=>({...v,from:value}));}}/></Field><Field label="Bis"><input type="date" required value={filter.to} min={filter.from} onInput={e=>{const value=e.currentTarget.value;setFilter(v=>({...v,to:value}));}}/></Field><Field label="Nachrichtenart"><select value={filter.direction} onChange={e=>setFilter(v=>({...v,direction:e.target.value}))}><option value="ALL">Alle Nachrichten</option><option value="EXCLUDE_HQ">Zentralennachrichten ausblenden</option><option value="ONLY_HQ">Nur Zentralennachrichten</option></select></Field><Field label="Maximale Prioritätsstufe"><select value={filter.maxPriority} onChange={e=>setFilter(v=>({...v,maxPriority:Number(e.target.value)}))}>{[0,1,2,3].map(n=><option key={n}>{n}</option>)}</select></Field><button className="button primary" disabled={busy||!filter.states.length}>Anzeigen</button></div><div className="admin-toolbar">{states.map((s,i)=><label key={s} className="admin-check"><input type="checkbox" checked={filter.states.includes(i)} onChange={e=>setFilter(v=>({...v,states:e.target.checked?[...v.states,i]:v.states.filter(n=>n!==i)}))}/>{s}</label>)}</div><CourierSearch label="Transporteur / Endgerät filtern" value={courier} onSelect={setCourier}/></form>
{busy&&<LoaderCircle className="spin"/>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Zeit / Status</th><th>Absender / Empfänger</th><th>Nachricht</th><th>Aktionen</th></tr></thead><tbody>{data.items.map(m=><tr key={m.id}><td>#{m.id} · {when(m.created)}<br/>{states[m.state]||m.state}<small>Zustellung: {when(m.delivery)}<br/>Bestätigung: {when(m.acknowledged)}{m.reader&&<><br/>Gelesen von: {m.reader}</>}{m.answerer&&<><br/>Beantwortet von: {m.answerer}</>}</small></td><td>{m.sender||"Unbekannt"}<br/>→ {m.recipient||"Zentrale"}</td><td><strong>{m.subject}</strong><p className="message-text">{m.body}</p>{m.replyTo>0&&<small>Antwort auf #{m.replyTo}</small>}</td><td>{m.canRead&&<button className="button" disabled={busy} onClick={()=>void read(m)}>Als gelesen markieren</button>}{m.canReply&&<button className="button" disabled={busy} onClick={()=>setComposer(m)}>Antworten</button>}</td></tr>)}</tbody></table>{!busy&&!data.items.length&&<p>Keine Nachrichten für diese Filter.</p>}</div><Pages page={query.page} total={data.total} onChange={p=>setQuery(v=>({...v,page:p}))} busy={busy}/>
<div className="admin-title"><h3>Nachrichtengruppen</h3>{options.manageGroups&&<button className="button" onClick={()=>setGroupEditor("new")}>Neue Nachrichtengruppe</button>}</div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Gruppe</th><th>Zugeordnete Gruppen</th>{options.manageGroups&&<th>Aktionen</th>}</tr></thead><tbody>{groups.map(g=><tr key={g.id}><td>{g.name}</td><td>{g.groups.length}</td>{options.manageGroups&&<td><button className="button" onClick={()=>setGroupEditor(g)}>Bearbeiten</button><button className="button" onClick={()=>setMembers(g)}>Zuordnungsliste</button><button className="button danger" onClick={()=>setRemoving(g)}>Löschen</button></td>}</tr>)}</tbody></table></div>
{composer&&<MessageComposer options={options} groups={groups} reply={composer==="new"?null:composer} onDirty={onDirty} onClose={()=>{setComposer(null);setReload(v=>v+1);}}/>}
{groupEditor&&<MessageGroupEditor group={groupEditor} onDirty={onDirty} onClose={()=>setGroupEditor(null)} onSaved={()=>{setGroupEditor(null);setReload(v=>v+1);}}/>}
{members&&<MessageGroupMembers group={members} onClose={()=>{setMembers(null);setReload(v=>v+1);}}/>}
{removing&&<Modal title="Nachrichtengruppe löschen" onClose={()=>{if(!busy)setRemoving(null);}}><div className="admin-dialog-body"><p>„{removing.name}“ und ihre Transporteurszuordnungen dauerhaft entfernen?</p><ErrorMessage error={error}/><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Gruppe löschen</button></div></div></Modal>}</>;
}
function CourierSearch({label,value,onSelect}:{label:string;value:Option|null;onSelect:(v:Option|null)=>void}){
const [q,setQ]=useState(""),[items,setItems]=useState<Option[]>([]),[error,setError]=useState("");
useEffect(()=>{if(value)return;let live=true;const timer=setTimeout(()=>request<Option[]>(`/messages/couriers?q=${encodeURIComponent(q)}`).then(d=>{if(live){setItems(d);setError("");}}).catch(e=>{if(live)setError(e.message);}),250);return()=>{live=false;clearTimeout(timer);};},[q,value]);
return <div className="message-lookup"><ErrorMessage error={error}/>{value?<div className="admin-toolbar"><strong>{label}: {value.label}</strong>{value.device&&<span>Endgerät: {value.device}</span>}<button className="button" type="button" onClick={()=>onSelect(null)}>Auswahl ändern</button></div>:<div className="admin-toolbar"><Field label={label}><input type="search" value={q} placeholder="Fahrzeugnummer oder Name" onChange={e=>setQ(e.target.value)}/></Field><Field label="Treffer auswählen"><select value="" onChange={e=>onSelect(items.find(v=>v.id===Number(e.target.value))||null)}><option value="">Keine Auswahl</option>{items.map(c=><option key={c.id} value={c.id}>{c.label}{c.device?` · ${c.device}`:""}</option>)}</select></Field><small>Bis zu 100 Treffer; Suche bei Bedarf eingrenzen.</small></div>}</div>;
}
function MessageComposer({options,groups,reply,onDirty,onClose}:{options:Options;groups:Group[];reply:Message|null;onDirty:(v:boolean)=>void;onClose:()=>void}){
const [subject,setSubject]=useState(reply?.subject?`AW: ${reply.subject}`.slice(0,40):""),[body,setBody]=useState(""),[mode,setMode]=useState("courier"),[courier,setCourier]=useState<Option|null>(null),[selected,setSelected]=useState<number[]>([]),[preview,setPreview]=useState<Preview|null>(null),[result,setResult]=useState<SendResult|null>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false);const [discard,dialog]=useDiscardConfirmation();
const dirty=!result&&!!(body||subject||courier||selected.length);useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Nachricht verwerfen?")))onClose();}
async function prepare(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{setPreview(await post<Preview>("/messages/preview",{subject,body,courier:!reply&&mode==="courier"?courier?.id:null,groups:!reply&&mode==="groups"?selected:[],allActive:!reply&&mode==="all",replyTo:reply?.id||null,revision:reply?.revision||null}));setAttempted(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function send(){if(!preview)return;setBusy(true);setAttempted(true);setError("");try{setResult(await post<SendResult>(`/messages/send/${preview.token}`));}catch(e){setError(`${(e as Error).message} Die Übergabe ist möglicherweise schon erfolgt. „Ergebnis abrufen“ verwendet denselben Versandauftrag.`);}finally{setBusy(false);}}
return <><Modal title={reply?`Antwort auf Nachricht #${reply.id}`:"Neue Endgerätenachricht"} onClose={close} wide><div className="admin-dialog-body"><ErrorMessage error={error}/>{result?<><h3>{result.queued} von {result.total} Nachrichten übergeben</h3><p>Die Historie zeigt den weiteren Zustell- und Lesestatus.</p>{result.warning&&<div className="alert error" role="alert">{result.warning}</div>}{(result.uncertain>0||result.unattempted>0)&&<p>{result.uncertain} unbestätigte Übergabe · {result.unattempted} Empfänger noch nicht versucht</p>}<button className="button primary" onClick={onClose}>Zur Historie</button></>:preview?<><p><strong>{preview.recipients.length} Empfänger</strong></p><div className="message-recipients">{preview.recipients.map(p=><div key={p.id}>{p.label}</div>)}</div><h3>{preview.subject||"Ohne Betreff"}</h3><p className="message-text">{preview.body}</p><p className="form-hint">Mit „Jetzt senden“ wird die Nachricht zur Zustellung an diese Endgeräte übergeben.</p><div className="admin-row-actions"><button className="button" disabled={busy||attempted} onClick={()=>setPreview(null)}>Bearbeiten</button><button className="button primary" disabled={busy} onClick={()=>void send()}>{attempted?"Ergebnis abrufen":"Jetzt senden"}</button></div></>:<form onSubmit={prepare}><fieldset disabled={busy}>{!reply&&<><Field label="Empfängerauswahl"><select value={mode} onChange={e=>setMode(e.target.value)}><option value="courier">Einzelner Transporteur</option><option value="groups">Nachrichtengruppen</option>{options.allActive&&<option value="all">Alle aktiven Fahrer der freigegebenen Niederlassungen</option>}</select></Field>{mode==="courier"&&<CourierSearch label="Transporteur" value={courier} onSelect={setCourier}/>} {mode==="groups"&&<div className="admin-toolbar">{groups.map(g=><label className="admin-check" key={g.id}><input type="checkbox" checked={selected.includes(g.id)} onChange={e=>setSelected(v=>e.target.checked?[...v,g.id]:v.filter(id=>id!==g.id))}/>{g.name}</label>)}</div>}</>}<Field label="Betreff"><input maxLength={40} value={subject} onChange={e=>setSubject(e.target.value)}/></Field><Field label="Nachricht"><textarea required rows={6} maxLength={options.maxMessage} value={body} onChange={e=>setBody(e.target.value)}/></Field><p className="form-hint">{body.length} / {options.maxMessage} Zeichen</p><div className="admin-row-actions"><button className="button" type="button" onClick={close}>Abbrechen</button><button className="button primary">Empfänger und Nachricht prüfen</button></div></fieldset></form>}</div></Modal>{dialog}</>;
}
function MessageGroupEditor({group,onDirty,onClose,onSaved}:{group:Group|"new";onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const [name,setName]=useState(group==="new"?"":group.name),[selected,setSelected]=useState(group==="new"?[]:group.groups),[available,setAvailable]=useState<Option[]>([]),[error,setError]=useState(""),[busy,setBusy]=useState(false);const [discard,dialog]=useDiscardConfirmation();const dirty=name!==(group==="new"?"":group.name)||JSON.stringify(selected)!==JSON.stringify(group==="new"?[]:group.groups);
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);useEffect(()=>{let live=true;request<Option[]>("/messages/generic-groups").then(v=>{if(live)setAvailable(v);}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Gruppendaten verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const body={name,groups:selected,revision:group==="new"?null:group.revision};if(group==="new")await post("/messages/groups",body);else await put(`/messages/groups/${group.id}`,body);onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><Modal title={group==="new"?"Neue Nachrichtengruppe":"Nachrichtengruppe bearbeiten"} onClose={close}><form className="admin-dialog-body" onSubmit={save}><ErrorMessage error={error}/><fieldset disabled={busy}><Field label="Bezeichnung"><input required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></Field><h3>Zugeordnete Gruppen</h3><p className="form-hint">Transporteure dieser Gruppen und ihrer Untergruppen werden bei der Empfängerauswahl berücksichtigt.</p><div className="message-recipients">{available.map(g=><label className="admin-check" key={g.id}><input type="checkbox" checked={selected.includes(g.id)} onChange={e=>setSelected(v=>e.target.checked?[...v,g.id]:v.filter(id=>id!==g.id))}/>{g.label}</label>)}</div><div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Speichern</button></div></fieldset></form></Modal>{dialog}</>;
}
function MessageGroupMembers({group,onClose}:{group:Group;onClose:()=>void}){
const [items,setItems]=useState<Member[]>([]),[q,setQ]=useState(""),[only,setOnly]=useState(true),[busy,setBusy]=useState(false),[reload,setReload]=useState(0),[error,setError]=useState("");
useEffect(()=>{let live=true;setBusy(true);request<Member[]>(`/messages/groups/${group.id}/members`).then(v=>{if(live)setItems(v);}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[group.id,reload]);
async function change(m:Member){setBusy(true);setError("");try{await put(`/messages/groups/${group.id}/members/${m.id}`,{revision:m.revision,member:!m.member});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title={`Zuordnungsliste: ${group.name}`} onClose={()=>{if(!busy)onClose();}} wide><div className="admin-dialog-body"><ErrorMessage error={error}/><div className="admin-toolbar"><Field label="Transporteur suchen"><input value={q} onChange={e=>setQ(e.target.value)}/></Field><label className="admin-check"><input type="checkbox" checked={only} onChange={e=>setOnly(e.target.checked)}/>Nur direkt zugeordnete Transporteure</label></div><p className="form-hint">Zusätzliche Empfänger aus verknüpften Gruppen werden in der Versandvorschau angezeigt.</p><div className="message-recipients"><table className="admin-table"><tbody>{items.filter(i=>(!only||i.member)&&i.label.toLowerCase().includes(q.toLowerCase())).map(m=><tr key={m.id}><td>{m.label}</td><td><button className="button" disabled={busy} onClick={()=>void change(m)}>{m.member?"Zuordnung entfernen":"Zuordnen"}</button></td></tr>)}</tbody></table></div><button className="button" disabled={busy} onClick={onClose}>Schließen</button></div></Modal>;
}