Ergänze Auftragsbearbeitung, Disposition, Stammdaten, Kunden- und Transporteurportale, Lagerverwaltung, Abrechnung, Exporte und Statistik. Füge Kontoeinstellungen, Mitteilungen, Automatikdienste und Betriebsstatus sowie die zugehörigen Tests und PHP-Abgleichsdokumentation hinzu. Vergrößere die persönlichen Auftragslisten auf einheitliche Höhen und richte Statusanzeigen sowie die Ranking-Vorschau-Steuerelemente bündig aus. Validierung: 44 Frontendtests und Produktionsbuild erfolgreich. Backend funktional unverändert gegenüber dem vollständigen Lauf mit 1.624 erfolgreichen und drei übersprungenen Tests. Layout im Browser und lokale Bereitstellung geprüft.
183 lines
27 KiB
TypeScript
183 lines
27 KiB
TypeScript
import {OperationsMonitoring} from './OperationsMonitoring';
|
|
import {useUiText} from './UiLanguage';
|
|
import {WarehouseUsers} from './WarehouseUsers';
|
|
import type {User} from './types';
|
|
import type {WorkspaceEntity} from './WorkspaceHeadquarters';
|
|
import {HeadquartersSettings} from './HeadquartersSettings';
|
|
import {PdaLocatingSettings} from './PdaLocatingSettings';
|
|
import {EmployeeAdministration} from './EmployeeAdministration';
|
|
import {WarehouseAdministration} from './WarehouseAdministration';
|
|
import {ArticleAdministration} from './ArticleAdministration';
|
|
import {VehicleDisposition} from './VehicleDisposition';
|
|
import { lazy, Suspense, createContext, useContext, useEffect, useRef, useState, type FormEvent } from "react";
|
|
import { CalendarDays, Clock3, FolderTree, FileText, Info, LoaderCircle, Plus, Save, Trash2 } from "lucide-react";
|
|
const FleetMap = lazy(() => import("./FleetMap").then(m=>({default:m.FleetMap})));
|
|
import { ZonePrices } from "./ZonePrices";
|
|
import { ServicePrices } from "./ServicePrices";
|
|
import { AddressMappings } from "./AddressMappings";
|
|
import { Filters } from "./Filters";
|
|
import { Evaluations } from "./Newsletters";
|
|
import { ExtrasStatistics } from "./ExtrasStatistics";
|
|
import { Reports } from "./Reports";
|
|
import { request, put, post } from "./api";
|
|
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
|
|
|
|
type Option = { id: string; label: string };
|
|
type Config = { operations:boolean; warehouseUsers:boolean; warehouses:boolean; articles:boolean; headquartersSettings:boolean; employees:boolean; pdaSettings:boolean; vehicleDisposition:boolean; fleetMap:boolean; extras:boolean; 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 = "operations" | "warehouseUsers" | "warehouses" | "articles" | "headquartersSettings" | "pdaSettings" | "employees" | "vehicleDisposition" | "fleetMap" | "extras" | "postalPrices" | "postalDiscounts" | "areaPrices" | "areaDiscounts" | "servicePrices" | "serviceDiscounts" | "postalAddresses" | "areaAddresses" | "filters" | "evaluations" | "customerReports" | "courierReports" | "holidays" | "travelTimes" | "groups" | "imprint";
|
|
const sections = [{id:"warehouseUsers",label:"Lagermitarbeiter",icon:FolderTree},{id:"warehouses",label:"Lagerorte",icon:FolderTree},{id:"articles",label:"Artikel",icon:FolderTree},{id:"headquartersSettings",label:"Niederlassungen",icon:FolderTree},{id:"employees",label:"Mitarbeiter",icon:FolderTree},{id:"pdaSettings",label:"PDA-Ortung",icon:Clock3},{id:"vehicleDisposition",label:"Fahrzeugdisposition",icon:CalendarDays},{id:"fleetMap",label:"Kartenübersicht",icon:FolderTree},{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:"extras",label:"Kessel Buntes",icon:FileText}, {id:"operations",label:"Betriebsstatus",icon:Clock3}, { 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,workspaceHeadquarters,onWorkspaceChanged }: { refresh: number;onDirtyChange:(dirty:boolean)=>void;workspaceHeadquarters:number;onWorkspaceChanged:(user:User,target:WorkspaceEntity)=>void }) {
|
|
const uiText=useUiText("admin/menu.php");
|
|
|
|
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}/>{uiText(s.label)}</button>)}</aside><section className="panel admin-panel" key={active}>
|
|
{active==="operations"&&<OperationsMonitoring refresh={refresh}/>}
|
|
{active==="warehouseUsers"&&<WarehouseUsers refresh={refresh} onDirtyChange={setDirty}/> }
|
|
{active==="warehouses"&&<WarehouseAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{active==="articles"&&<ArticleAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{active==="headquartersSettings"&&<HeadquartersSettings refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{active==="pdaSettings"&&<PdaLocatingSettings refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{active==="employees"&&<EmployeeAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{active==="vehicleDisposition"&&<VehicleDisposition refresh={refresh} onDirtyChange={setDirty} workspaceHeadquarters={workspaceHeadquarters} onWorkspaceChanged={onWorkspaceChanged}/>}
|
|
{active==="fleetMap"&&<Suspense fallback={<p>Karte wird geladen …</p>}><FleetMap refresh={refresh} onDirtyChange={setDirty}/></Suspense>}
|
|
{active==="extras"&&<ExtrasStatistics refresh={refresh} onDirtyChange={setDirty}/>}
|
|
{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>{uiText("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>{uiText("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 uiText=useUiText("admin/menu.php");
|
|
|
|
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>{uiText("Feiertage")}</h2><p>Jahreskalender und Übernahme aus einer Vorlage oder einem Vorjahr.</p></div><strong>{draft.length} {uiText(" 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}/>{uiText("Ä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>{uiText("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 uiText=useUiText("admin/menu.php");
|
|
|
|
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>{uiText("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>{uiText("PLZ")}</th><th>{uiText("Land")}</th><th>Zeit in Minuten</th><th>{uiText("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}/>{uiText("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 uiText=useUiText("admin/menu.php");
|
|
|
|
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>{uiText("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)}>{uiText("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 uiText=useUiText("admin/menu.php");
|
|
|
|
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={uiText("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">{uiText("Speichern")}</button></div></fieldset></form></Modal>{discardDialog}</>;
|
|
}
|
|
function GroupMembers({group,headquarters,kinds,onClose}:{group:Group;headquarters:number;kinds:Option[];onClose:()=>void}) {
|
|
const uiText=useUiText("admin/menu.php");
|
|
|
|
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">{uiText("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>{uiText("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":uiText("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>;
|
|
}
|