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.
This commit is contained in:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import { createContext, useContext, useEffect, useRef, useState, type FormEvent } from "react";
import { CalendarDays, Clock3, FolderTree, FileText, Info, LoaderCircle, Plus, Save, Trash2 } from "lucide-react";
import { ZonePrices } from "./ZonePrices";
import { ServicePrices } from "./ServicePrices";
import { AddressMappings } from "./AddressMappings";
import { Filters } from "./Filters";
import { Evaluations } from "./Newsletters";
import { Reports } from "./Reports";
import { request, put, post } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
type Option = { id: string; label: string };
type Config = { postalPrices:boolean; postalDiscounts:boolean; areaPrices:boolean; areaDiscounts:boolean; servicePrices:boolean; serviceDiscounts:boolean; postalAddresses:boolean; areaAddresses:boolean; filters:boolean; evaluations:boolean; headquartersId: number; holidays: boolean; travelTimes: boolean; groups: boolean; sharedGroups: boolean; customerReports: boolean; courierReports: boolean; groupKinds: Option[] };
type Section = "postalPrices" | "postalDiscounts" | "areaPrices" | "areaDiscounts" | "servicePrices" | "serviceDiscounts" | "postalAddresses" | "areaAddresses" | "filters" | "evaluations" | "customerReports" | "courierReports" | "holidays" | "travelTimes" | "groups" | "imprint";
const sections = [{id:"postalPrices",label:"PLZ-Preise",icon:FileText},{id:"postalDiscounts",label:"PLZ-Rabatte",icon:FileText},{id:"areaPrices",label:"Bereichspreise",icon:FileText},{id:"areaDiscounts",label:"Bereichsrabatte",icon:FileText},{id:"servicePrices",label:"Servicepreise",icon:FileText},{id:"serviceDiscounts",label:"Servicerabatte",icon:FileText},{id:"postalAddresses",label:"PLZ-Adressen",icon:FolderTree},{id:"areaAddresses",label:"Bereichsadressen",icon:FolderTree},{id:"filters",label:"Auftragsfilter",icon:FolderTree},{id:"evaluations",label:"Bewertungen",icon:FileText},{ id: "customerReports", label: "Kundenberichte", icon: FileText }, { id: "courierReports", label: "Transporteursberichte", icon: FileText }, { id: "holidays", label: "Feiertage", icon: CalendarDays }, { id: "travelTimes", label: "Anfahrtszeiten", icon: Clock3 }, { id: "groups", label: "Gruppen", icon: FolderTree }, { id: "imprint", label: "Impressum", icon: Info }] as const;
const dateString = (year: number, month: number, day: number) => `${year}-${String(month).padStart(2,"0")}-${String(day).padStart(2,"0")}`;
const monthName = (month: number) => new Date(2024,month-1,1).toLocaleDateString("de-DE",{month:"long"});
const DraftContext=createContext<(changed:boolean)=>void>(()=>{});
function useDraftGuard(changed:boolean) {
const setDirty=useContext(DraftContext);
useEffect(()=>{setDirty(changed);return()=>setDirty(false);},[changed,setDirty]);
}
function Message({ error, notice }: { error: string; notice?: string }) {
return <>{error && <div className="alert error" role="alert">{error}</div>}{notice && <p className="admin-success" role="status">{notice}</p>}</>;
}
function useResource<T>(path: string, refresh: number) {
const [data,setData] = useState<T|null>(null), [error,setError] = useState(""), [loading,setLoading] = useState(true);
useEffect(()=>{let active=true;setLoading(true);setError("");setData(null);request<T>(path).then(d=>{if(active)setData(d);}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setLoading(false);});return()=>{active=false;};},[path,refresh]);
return {data,setData,error,setError,loading};
}
export function Administration({ refresh,onDirtyChange }: { refresh: number;onDirtyChange:(dirty:boolean)=>void }) {
const [dirty,setDirty]=useState(false);
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
const {data:config,error,loading}=useResource<Config>("/administration",refresh);
const [section,setSection]=useState<Section|null>(null);
const active=section||sections.find(s=>s.id==="imprint"||config?.[s.id])?.id||"imprint";
if(loading)return <div className="panel admin-panel"><LoaderCircle className="spin"/> Verwaltung wird geladen …</div>;
if(!config)return <Message error={error}/>;
return <DraftContext.Provider value={setDirty}><div className="administration"><aside className="admin-navigation" aria-label="Verwaltungsbereiche">{sections.filter(s=>s.id==="imprint"||config[s.id]).map(s=><button key={s.id} className={active===s.id?"active":""} onClick={async()=>{if(active===s.id||!dirty||await confirmDiscard("Ungespeicherte Änderungen verwerfen?"))setSection(s.id);}}><s.icon size={18}/>{s.label}</button>)}</aside><section className="panel admin-panel" key={active}>
{active==="postalPrices"&&<ZonePrices kind="POSTAL" discount={false} onDirty={setDirty}/>}
{active==="postalDiscounts"&&<ZonePrices kind="POSTAL" discount onDirty={setDirty}/>}
{active==="areaPrices"&&<ZonePrices kind="AREA" discount={false} onDirty={setDirty}/>}
{active==="areaDiscounts"&&<ZonePrices kind="AREA" discount onDirty={setDirty}/>}
{active==="servicePrices"&&<ServicePrices discount={false} onDirty={setDirty}/>}
{active==="serviceDiscounts"&&<ServicePrices discount onDirty={setDirty}/>}
{active==="postalAddresses"&&<AddressMappings kind="POSTAL" onDirty={setDirty}/>}
{active==="areaAddresses"&&<AddressMappings kind="AREA" onDirty={setDirty}/>}
{active==="filters"&&<Filters onDirty={setDirty}/>}
{active==="evaluations"&&<Evaluations onDirty={setDirty}/>}
{active==="customerReports"&&<Reports kind="CUSTOMER" refresh={refresh} onDirtyChange={setDirty}/>}
{active==="courierReports"&&<Reports kind="COURIER" refresh={refresh} onDirtyChange={setDirty}/>}
{active==="holidays"&&<Holidays refresh={refresh}/>}
{active==="travelTimes"&&<TravelTimes refresh={refresh}/>}
{active==="groups"&&<Groups refresh={refresh} config={config}/>}
{active==="imprint"&&<><h2>Impressum</h2><p><strong>Assecutor Data Service GmbH</strong></p><p>Carsten Annacker<br/>Gunnar Timm<br/>Marc Vollmann</p><p>Gerhart-Hauptmann-Weg 14<br/>21502 Geesthacht</p><p>E-Mail: <a href="mailto:contact@assecutor.de">contact@assecutor.de</a><br/>Web: <a href="http://www.assecutor.de" target="_blank" rel="noreferrer">www.assecutor.de</a></p></>}
</section></div>{discardDialog}</DraftContext.Provider>;
}
type Holiday = {month:number;day:number;name:string};
type HolidayData = {year:number;today:string;revision:string;days:Holiday[];years:{year:number}[]};
function Holidays({refresh}:{refresh:number}) {
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
const now=new Date(), [year,setYear]=useState(now.getFullYear()),[month,setMonth]=useState(now.getMonth()+1),[copyYear,setCopyYear]=useState("0"),[busy,setBusy]=useState(false),[notice,setNotice]=useState("");
const {data,setData,error,setError,loading}=useResource<HolidayData>(`/administration/holidays/${year}`,refresh);
const [draft,setDraft]=useState<Holiday[]>([]);
useEffect(()=>{if(data){setDraft(data.days);}},[data]);
const changed=!!data&&JSON.stringify(draft)!==JSON.stringify(data.days);
useDraftGuard(changed);
const selectable=[...new Set([0,...Array.from({length:11},(_,i)=>now.getFullYear()+i),...(data?.years.map(y=>y.year)||[])])].sort((a,b)=>b-a);
function update(m:number,d:number,checked:boolean,name:string) {setDraft(v=>[...v.filter(x=>x.month!==m||x.day!==d),...(checked?[{month:m,day:d,name}]:[])].sort((a,b)=>a.month-b.month||a.day-b.day));}
async function copy() {
if(!data)return;setBusy(true);setError("");
try {
const source=await request<HolidayData>(`/administration/holidays/${copyYear}`);
const next=[...draft];let count=0;
for(const d of source.days) {
if(d.day>new Date(year,d.month,0).getDate()||dateString(year,d.month,d.day)<=data.today||next.some(x=>x.month===d.month&&x.day===d.day))continue;
next.push(d);count++;
}
setDraft(next.sort((a,b)=>a.month-b.month||a.day-b.day));setNotice(`${count} Feiertage übernommen. Zum Übernehmen der Änderungen bitte speichern.`);
}catch(e){setError((e as Error).message);}finally{setBusy(false);}
}
async function save(e:FormEvent) {e.preventDefault();if(!data)return;setBusy(true);setError("");try{setData(await put<HolidayData>(`/administration/holidays/${year}`,{revision:data.revision,days:draft}));setNotice("Feiertage gespeichert.");}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><div className="admin-title"><div><h2>Feiertage</h2><p>Jahreskalender und Übernahme aus einer Vorlage oder einem Vorjahr.</p></div><strong>{draft.length} Feiertage</strong></div><Message error={error} notice={notice}/>
<form onSubmit={save}><fieldset disabled={busy||loading}><div className="admin-toolbar"><Field label="Jahr"><select value={year} onChange={async e=>{const next=Number(e.target.value);if(!changed||await confirmDiscard("Ungespeicherte Feiertagsänderungen verwerfen?")){setYear(next);setNotice("");}}}>{selectable.map(y=><option key={y} value={y}>{y||"Default-Vorlage"}</option>)}</select></Field><Field label="Monat"><select value={month} onChange={e=>setMonth(Number(e.target.value))}>{Array.from({length:12},(_,i)=><option key={i} value={i+1}>{monthName(i+1)}</option>)}</select></Field><button className="button primary" type="submit" disabled={!changed||year<now.getFullYear()}><Save size={16}/>Änderungen speichern</button></div>
{loading?<LoaderCircle className="spin"/>:data&&<><p className="admin-hint">Vergangene Tage und heute bleiben geschützt.</p><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Datum</th><th>Feiertag</th><th>Bezeichnung</th></tr></thead><tbody>{Array.from({length:new Date(year||2000,month,0).getDate()},(_,i)=>{
const d=i+1,h=draft.find(v=>v.month===month&&v.day===d),label=dateString(year||2000,month,d),locked=!year||label<=data.today;
return <tr key={d} className={locked?"admin-locked":""}><td>{new Date(year||2000,month-1,d).toLocaleDateString("de-DE",{weekday:"short",day:"2-digit",month:"2-digit"})}</td><td><input type="checkbox" aria-label={`Feiertag ${label}`} checked={!!h} disabled={locked} onChange={e=>update(month,d,e.target.checked,h?.name||"")}/></td><td><input aria-label={`Bezeichnung ${label}`} value={h?.name||""} disabled={locked||!h} maxLength={50} placeholder="Name des Feiertags" onChange={e=>update(month,d,true,e.target.value)}/></td></tr>;
})}</tbody></table></div><div className="admin-toolbar admin-copy"><Field label="Feiertage übernehmen aus"><select value={copyYear} onChange={e=>setCopyYear(e.target.value)}>{selectable.filter(y=>y!==year).map(y=><option key={y} value={y}>{y||"Default-Vorlage"}</option>)}</select></Field><button className="button" type="button" disabled={year<now.getFullYear()} onClick={()=>void copy()}>In dieses Jahr übernehmen</button></div></>}
</fieldset></form>{discardDialog}</>;
}
type TravelRow={id:number;postalCode:string;country:string;minutes:number|null;revision:string};
function TravelTimes({refresh}:{refresh:number}) {
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
const [mode,setMode]=useState("CLASSIC"),[from,setFrom]=useState(""),[to,setTo]=useState(""),[rows,setRows]=useState<TravelRow[]>([]),[values,setValues]=useState<Record<number,string>>({}),[busy,setBusy]=useState(false),[error,setError]=useState(""),[notice,setNotice]=useState(""),[loaded,setLoaded]=useState(false),[defaultMinutes,setDefaultMinutes]=useState<number|null>(null);
const changed=rows.some(r=>String(r.minutes??"")!==values[r.id]);
useDraftGuard(changed);
const lastRefresh=useRef(refresh);
useEffect(()=>{if(lastRefresh.current!==refresh){lastRefresh.current=refresh;setRows([]);setValues({});setLoaded(false);}},[refresh]);
async function load(e:FormEvent) {e.preventDefault();if(changed&&!(await confirmDiscard("Ungespeicherte Anfahrtszeiten verwerfen?")))return;setBusy(true);setError("");setNotice("");try{const data=await request<{items:TravelRow[];defaultMinutes:number|null}>("/administration/travel-times?"+new URLSearchParams({mode,from,to}));setDefaultMinutes(data.defaultMinutes);setRows(data.items);setValues(Object.fromEntries(data.items.map(r=>[r.id,String(r.minutes??"")])));setLoaded(true);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function save(row:TravelRow) {setBusy(true);setError("");setNotice("");try{const saved=await put<TravelRow>(`/administration/travel-times/${row.id}?mode=${mode}`,{revision:row.revision,minutes:Number(values[row.id])});setRows(v=>v.map(r=>r.id===saved.id?saved:r));setValues(v=>({...v,[saved.id]:String(saved.minutes)}));setNotice(`Anfahrtszeit für ${saved.postalCode} gespeichert.`);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><h2>Anfahrtszeiten</h2><p>{mode==="CLASSIC"?"Vorgaben je PLZ für Ihre Niederlassung.":"BWV-Vorgaben gelten zentral für alle Niederlassungen."}</p><Message error={error} notice={notice}/><form onSubmit={load}><fieldset disabled={busy}><div className="admin-toolbar"><Field label="Betriebsart"><select value={mode} onChange={async e=>{const next=e.target.value;if(!changed||await confirmDiscard("Ungespeicherte Anfahrtszeiten verwerfen?")){setMode(next);setRows([]);setValues({});setLoaded(false);setNotice("");}}}><option>CLASSIC</option><option>BWV</option></select></Field><Field label="PLZ von"><input required maxLength={10} value={from} onChange={e=>setFrom(e.target.value)}/></Field><Field label="PLZ bis"><input required maxLength={10} value={to} onChange={e=>setTo(e.target.value)}/></Field><button className="button primary">Liste zusammenstellen</button></div></fieldset></form>
<fieldset disabled={busy}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>PLZ</th><th>Land</th><th>Zeit in Minuten</th><th>Aktion</th></tr></thead><tbody>{rows.map(r=><tr key={r.id}><td>{r.postalCode}</td><td>{r.country}</td><td><input type="number" min={0} max={9999} step={1} aria-label={`Anfahrtszeit ${r.postalCode} ${r.country}`} value={values[r.id]??""} onChange={e=>setValues(v=>({...v,[r.id]:e.target.value}))}/></td><td><button type="button" className="button" disabled={String(r.minutes??"")===values[r.id]} onClick={()=>void save(r)}><Save size={15}/>Speichern</button></td></tr>)}</tbody></table></div></fieldset>{loaded&&<p className="admin-hint">{rows.length} PLZ gefunden · Änderungen werden pro Zeile gespeichert. {defaultMinutes!==null?`Leer oder 0 übernimmt den Niederlassungsstandard: ${defaultMinutes} Minuten.`:"Kein gültiger Niederlassungsstandard konfiguriert."}</p>}{busy&&<LoaderCircle className="spin"/>}{discardDialog}</>;
}
type Group = { id:number;name:string;shared:boolean;readOnly:boolean;revision:string };
type Member = { id:number;label:string;member:boolean;revision:string };
function Groups({refresh,config}:{refresh:number;config:Config}) {
const [hq,setHq]=useState(config.headquartersId),[reload,setReload]=useState(0);
const suffix=`?headquartersId=${hq}`;
const {data:headquarters}=useResource<{id:number;label:string}[]>("/administration/groups/headquarters",refresh);
const {data:groups,error,setError,loading}=useResource<Group[]>("/administration/groups"+suffix,refresh+reload);
const {data:operations}=useResource<{token:string;name:string}[]>("/administration/groups/operations"+suffix,refresh+reload);
const [editor,setEditor]=useState<Group|"new"|null>(null),[selected,setSelected]=useState<Group|null>(null),[removing,setRemoving]=useState<Group|null>(null),[busy,setBusy]=useState(false),[notice,setNotice]=useState("");
const [plan,setPlan]=useState<{revision:string;total:number;counts:{label:string;count:number}[]}|null>(null),[deleteToken,setDeleteToken]=useState(""),[uncertain,setUncertain]=useState(false);
useDraftGuard(busy||uncertain||!!editor);
async function prepare(group:Group){setRemoving(group);setPlan(null);setBusy(true);setError("");setDeleteToken(crypto.randomUUID());try{setPlan(await request(`/administration/groups/${group.id}/deletion`+suffix));}catch(e){setError((e as Error).message);setRemoving(null);}finally{setBusy(false);}}
async function remove(resumeToken?:string) {
if(!resumeToken&&(!removing||!plan))return;setBusy(true);setError("");
try{
const result=resumeToken?await post<{state:string;message?:string}>(`/administration/groups/operations/${resumeToken}`+suffix,{}):await request<{state:string;message?:string}>(`/administration/groups/${removing!.id}`+suffix,{method:"DELETE",body:JSON.stringify({requestId:deleteToken,revision:plan!.revision})});
setUncertain(false);setRemoving(null);setSelected(null);setReload(v=>v+1);setNotice(result.state==="DONE"?"Gruppe und ihre Zuordnungen entfernt.":result.message||"Löschvorgang bitte fortsetzen.");
}catch(e){setError((e as Error).message);if(!resumeToken)setUncertain(true);}finally{setBusy(false);}
}
return <><div className="admin-title"><div><h2>Gruppen</h2><p>Gruppen und ihre Zuordnungen zu Kunden, Transporteuren und weiteren Stammdaten.</p></div><button className="button primary" disabled={busy} onClick={()=>setEditor("new")}><Plus size={16}/>Neue Gruppe</button></div><Message error={error} notice={notice}/>
<Field label="Niederlassung der Gruppen"><select disabled={busy||!!editor||!!selected||!!removing} value={hq} onChange={e=>{setHq(Number(e.target.value));setNotice("");}}>{(headquarters||[{id:config.headquartersId,label:"Eigene Niederlassung"}]).map(h=><option key={h.id} value={h.id}>{h.label}</option>)}</select></Field>
{operations?.map(o=><div className="alert info" key={o.token}>Offene Löschung: {o.name} <button className="button" disabled={busy} onClick={()=>void remove(o.token)}>Löschvorgang fortsetzen</button></div>)}
{loading?<LoaderCircle className="spin"/>:<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Gruppe</th><th>Sichtbarkeit</th><th>Aktionen</th></tr></thead><tbody>{groups?.map(g=><tr key={g.id}><td><strong>{g.name}</strong>{g.readOnly&&<small> · geschützt</small>}</td><td>{g.shared?"Alle Niederlassungen":headquarters?.find(h=>h.id===hq)?.label||"Gewählte Niederlassung"}</td><td className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setSelected(g)}>Mitglieder</button><button className="button" disabled={busy||g.readOnly} onClick={()=>setEditor(g)}>Bearbeiten</button><button className="icon-button" aria-label={`${g.name} löschen`} disabled={busy||g.readOnly} onClick={()=>void prepare(g)}><Trash2 size={16}/></button></td></tr>)}</tbody></table></div>}
{editor&&<GroupEditor group={editor} headquarters={hq} sharedAllowed={config.sharedGroups} onClose={()=>setEditor(null)} onSaved={()=>{setEditor(null);setReload(v=>v+1);setNotice("Gruppe gespeichert.");}}/>}
{selected&&<GroupMembers group={selected} headquarters={hq} kinds={config.groupKinds} onClose={()=>setSelected(null)}/>}
{removing&&<Modal title="Gruppe und Zuordnungen löschen" onClose={()=>{if(!busy&&!uncertain)setRemoving(null);}}><div className="admin-dialog-body"><Message error={error}/><p>„{removing.name}“ dauerhaft löschen?</p>{plan?<><p>{plan.total} Zuordnungen werden entfernt. Die zugeordneten Stammdaten bleiben erhalten.</p><ul>{plan.counts.map(c=><li key={c.label}>{c.label}: {c.count}</li>)}</ul></>:<p>Zuordnungen werden geprüft …</p>}{uncertain&&<p>Der Ausgang ist noch unklar. Bitte denselben Vorgang erneut prüfen.</p>}<div className="admin-row-actions"><button className="button" disabled={busy||uncertain} onClick={()=>setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy||!plan} onClick={()=>void remove()}>{uncertain?"Löschvorgang erneut prüfen":"Gruppe und Zuordnungen löschen"}</button></div></div></Modal>}
</>;
}
function GroupEditor({group,headquarters,sharedAllowed,onClose,onSaved}:{group:Group|"new";headquarters:number;sharedAllowed:boolean;onClose:()=>void;onSaved:()=>void}) {
const [name,setName]=useState(group==="new"?"":group.name),[shared,setShared]=useState(group==="new"?false:group.shared),[busy,setBusy]=useState(false),[error,setError]=useState("");
const changed=name!==(group==="new"?"":group.name)||shared!==(group==="new"?false:group.shared);
useDraftGuard(changed);
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
async function close(){if(!busy&&(!changed||await confirmDiscard("Ungespeicherte Gruppenänderungen verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const body={name,shared,revision:group==="new"?undefined:group.revision};if(group==="new")await post(`/administration/groups?headquartersId=${headquarters}`,body);else await put(`/administration/groups/${group.id}?headquartersId=${headquarters}`,body);onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><Modal title={group==="new"?"Neue Gruppe":"Gruppe bearbeiten"} onClose={close}><form onSubmit={save} className="admin-dialog-body"><Message error={error}/><fieldset disabled={busy}><Field label="Bezeichnung"><input autoFocus required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></Field>{sharedAllowed&&<label className="admin-check"><input type="checkbox" checked={shared} onChange={e=>setShared(e.target.checked)}/>Für alle Niederlassungen sichtbar</label>}<div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Speichern</button></div></fieldset></form></Modal>{discardDialog}</>;
}
function GroupMembers({group,headquarters,kinds,onClose}:{group:Group;headquarters:number;kinds:Option[];onClose:()=>void}) {
const [kind,setKind]=useState(kinds[0]?.id||"CUSTOMER"),[q,setQ]=useState(""),[search,setSearch]=useState(""),[onlyMembers,setOnlyMembers]=useState(true),[reload,setReload]=useState(0),[busy,setBusy]=useState(false);
const {data,error,setError,loading}=useResource<{items:Member[];truncated:boolean}>(`/administration/groups/${group.id}/members?`+new URLSearchParams({headquartersId:String(headquarters),kind,q:search,onlyMembers:String(onlyMembers)}),reload);
async function change(member:Member){setBusy(true);setError("");try{await put(`/administration/groups/${group.id}/members/${kind}/${member.id}?headquartersId=${headquarters}`,{revision:member.revision,member:!member.member});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title={`Mitglieder: ${group.name}`} onClose={()=>{if(!busy)onClose();}} wide><div className="admin-dialog-body"><Message error={error}/><form onSubmit={e=>{e.preventDefault();setSearch(q);}}><fieldset disabled={busy}><div className="admin-toolbar"><Field label="Mitgliedsart"><select value={kind} onChange={e=>{setKind(e.target.value);setSearch("");setQ("");}}>{kinds.map(k=><option key={k.id} value={k.id}>{k.label}</option>)}</select></Field><Field label="Suche"><input value={q} onChange={e=>setQ(e.target.value)} maxLength={100}/></Field><button className="button">Suchen</button><label className="admin-check"><input type="checkbox" checked={onlyMembers} onChange={e=>setOnlyMembers(e.target.checked)}/>Nur Mitglieder</label></div></fieldset></form>{loading?<LoaderCircle className="spin"/>:<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Bezeichnung</th><th>Mitgliedschaft</th></tr></thead><tbody>{data?.items.map(m=><tr key={m.id}><td>{m.label||`#${m.id}`}</td><td><button className="button" disabled={busy||group.readOnly} onClick={()=>void change(m)}>{m.member?"Entfernen":"Hinzufügen"}</button></td></tr>)}</tbody></table>{!data?.items.length&&<p>Keine Einträge gefunden.</p>}</div>}{data?.truncated&&<p className="admin-hint">Die ersten 500 Treffer werden angezeigt. Bitte die Suche eingrenzen.</p>}</div></Modal>;
}