Erweitere VoTiAn NG um Fachfunktionen und verbessere die Listenoberfläche

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.
This commit is contained in:
Sven committed 2026-10-20 11:19:43 +02:00
1 parent 363bc27c26
commit 781a2b82dc
1165 files changed
+74986 -1028

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import {useUiText} from './UiLanguage';
import {useEffect,useRef,useState} from 'react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {request,post} from './api';
import {ErrorMessage,uncertainWrite} from './BusinessForms';
type Kind='customers'|'couriers';
type Row={customer:number;courier:number;customerLabel:string;courierLabel:string;relation:number;position:number;commission:string;description:string;revision:string};
type Data={rows:Row[];revision:string;canEditCommission:boolean;operations:{token:string;canResume:boolean;description:string}[]};
type Target=Pick<Row,'customer'|'courier'|'revision'>;
type Command={requestId:string;action:string;targets:Target[];relation:number;description:string;commission:string|null;position:number;planRevision:string};
type Draft={action:string;targets:Target[];relation:number;description:string;commission:string;originalCommission:string;position:number;maxPosition:number;label:string};
type Preview={descriptions:string[];planRevision:string;commissionChanges:number};
type Choice={id:number;label:string};
const base='/master/relations';
const key=(r:Target)=>`${r.customer}-${r.courier}`;
const rate=(r:string)=>Number(r).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2});
const target=(r:Row):Target=>({customer:r.customer,courier:r.courier,revision:r.revision});
const blank=(action:string):Draft=>({action,targets:[],relation:0,description:'',commission:'0',originalCommission:'0',position:1,maxPosition:1,label:''});
export function CustomerCourierRelations({kind,id,onClose}:{kind:Kind;id:number;onClose:()=>void}){
const uiText=useUiText("admin/customer_courier.php");
const [data,setData]=useState<Data|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
const [query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[selected,setSelected]=useState<string[]>([]);
const [draft,setDraft]=useState<Draft|null>(null),[preview,setPreview]=useState<Preview|null>(null),[search,setSearch]=useState(''),[choices,setChoices]=useState<Choice[]>([]),[more,setMore]=useState(false),[searched,setSearched]=useState(false);
const [history,setHistory]=useState<{label:string;rows:{eid:string;value:string;at:string}[]}|null>(null);
const pending=useRef<Command|null>(null),original=useRef('');const [discard,discardDialog]=useDiscardConfirmation();
async function load(){const d=await request<Data>(`${base}/${kind}/${id}`);setData(d);setSelected([]);setPage(0);}
useEffect(()=>{setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));},[kind,id]);
const dirty=!!draft&&(JSON.stringify(draft)!==original.current||!!preview);
async function closeEditor(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Kunden–Transporteur-Beziehung verwerfen?')){setDraft(null);setPreview(null);pending.current=null;setError('');}}
function open(d:Draft){original.current=JSON.stringify(d);setDraft(d);setPreview(null);pending.current=null;setSearch('');setChoices([]);setSearched(false);setMore(false);setError('');}
function change(p:Partial<Draft>){setDraft(d=>d?{...d,...p}:d);setPreview(null);pending.current=null;setError('');}
async function edit(action:string,row:Row){setBusy(true);setError('');try{const context=await request<Data>(`${base}/customers/${row.customer}`);const current=context.rows.find(r=>r.courier===row.courier);if(!current)throw new Error('Diese Beziehung besteht nicht mehr. Bitte neu laden.');open({...blank(action),targets:[target(current)],relation:current.relation,description:current.description,commission:current.commission,originalCommission:current.commission,position:current.position,maxPosition:Math.max(1,...context.rows.filter(r=>r.relation===1).map(r=>r.position)),label:`${current.customerLabel} ↔ ${current.courierLabel}`});}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function remove(rows:Row[]){open({...blank('REMOVE'),targets:rows.map(target),label:rows.map(r=>`${r.customerLabel} ↔ ${r.courierLabel}`).join('\n')});}
async function find(){setBusy(true);setError('');try{const r=await request<{items:Choice[];more:boolean}>(`${base}/search?kind=${kind==='customers'?'couriers':'customers'}&q=${encodeURIComponent(search)}`);setChoices(r.items);setMore(r.more);setSearched(true);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function choose(c:Choice){setBusy(true);setError('');try{const customer=kind==='customers'?id:c.id,courier=kind==='couriers'?id:c.id;const context=await request<Data>(`${base}/customers/${customer}`);if(context.rows.some(r=>r.courier===courier))throw new Error('Zwischen diesem Kunden und Transporteur besteht bereits eine Beziehung. Bitte den vorhandenen Eintrag bearbeiten.');change({targets:[{customer,courier,revision:context.revision}],label:c.label});setChoices([]);setSearched(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function prepare(){if(!draft||!draft.targets.length)return;setBusy(true);setError('');try{const command:Command={requestId:crypto.randomUUID(),action:draft.action,targets:draft.targets,relation:draft.relation,description:draft.description,commission:data?.canEditCommission&&draft.relation===1&&draft.commission!==draft.originalCommission?draft.commission:null,position:draft.position,planRevision:''};const p=await post<Preview>(`${base}/preview`,command);pending.current={...command,planRevision:p.planRevision};setPreview(p);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function save(token?:string){if(!token&&!pending.current)return;setBusy(true);setError('');try{const r=await post<{state:string;message?:string}>(token?`${base}/operations/${token}`:base,token?undefined:pending.current);pending.current=null;setUncertain(false);setDraft(null);setPreview(null);if(r.state==='DONE')setNotice('Kunden–Transporteur-Beziehungen gespeichert.');else{setNotice('');setError(r.message||'Der gesicherte Vorgang ist noch offen.');}await load();}catch(e){setUncertain(!!pending.current&&uncertainWrite(e));setError((e as Error).message);}finally{setBusy(false);}}
async function showHistory(row:Row){setBusy(true);setError('');try{const rows=await request<{eid:string;value:string;at:string}[]>(`${base}/history?customer=${row.customer}&courier=${row.courier}`);setHistory({label:`${row.customerLabel} ↔ ${row.courierLabel}`,rows});}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const blocked=busy||uncertain||!!data?.operations.length;
const filtered=data?.rows.filter(r=>(filter==='all'||String(r.relation)===filter)&&`${r.customerLabel} ${r.courierLabel} ${r.description}`.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de')))??[];
const shown=filtered.slice(page*100,page*100+100);
const titles:Record<string,string>={ADD:'Beziehung anlegen',UPDATE:'Beziehung bearbeiten',REMOVE:'Beziehungen entfernen',MOVE:'Favoritenrang ändern'};
return <><Modal title={kind==='customers'?`Transporteursbeziehungen · Kunde ${id}`:`Kundenbeziehungen · Transporteur ${id}`} wide onClose={()=>{if(!draft&&!history&&!busy&&!uncertain)onClose();}}><div className="admin-dialog-body customer-relations">
{!draft&&<ErrorMessage message={error}/>} {notice&&<p className="admin-success" role="status">{notice}</p>}
<p>Kunden und Transporteure als „Gesperrt“ oder „Besonders erwünscht“ verknüpfen. Die Favoritenreihenfolge gilt je Kunde.</p>
{data?.operations.map(o=><section className="report-warning" key={o.token}><p>Ein gesicherter Beziehungsvorgang ist noch offen. {o.description}</p>{!o.canResume&&<p>Der ursprüngliche Bearbeiter muss den Vorgang fortsetzen.</p>}<button className="button" disabled={busy||uncertain||!o.canResume} onClick={()=>void save(o.token)}>Vorgang fortsetzen</button></section>)}
<div className="admin-toolbar"><Field label="Beziehungen suchen"><input type="search" value={query} onChange={e=>{setQuery(e.target.value);setPage(0);}}/></Field><Field label="Beziehungsstatus"><select value={filter} onChange={e=>{setFilter(e.target.value);setPage(0);}}><option value="all">Alle Beziehungen</option><option value="1">{uiText("Besonders erwünscht")}</option><option value="0">Gesperrt</option></select></Field><button className="button" disabled={busy||uncertain} onClick={()=>{setError('');setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));}}>Neu laden</button></div>
<div className="admin-row-actions"><button className="button primary" disabled={blocked||!data} onClick={()=>open(blank('ADD'))}>Neue Beziehung</button><button className="button" disabled={blocked||!selected.length} onClick={()=>remove(data!.rows.filter(r=>selected.includes(key(r))))}>Markierte entfernen ({selected.length})</button><span>{filtered.length} Beziehungen</span></div>
{!data&&<p>Beziehungen werden geladen …</p>}{data&&<div className="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="Sichtbare Beziehungen markieren" disabled={blocked||!shown.length} checked={!!shown.length&&shown.every(r=>selected.includes(key(r)))} onChange={e=>setSelected(s=>e.target.checked?[...new Set([...s,...shown.map(key)])]:s.filter(k=>!shown.some(r=>key(r)===k)))}/></th><th>{kind==='customers'?'Transporteur':uiText("Kunde")}</th><th>Status / Rang</th><th>{uiText("Provision")}</th><th>{uiText("Bemerkung")}</th><th>Aktionen</th></tr></thead><tbody>{shown.map(r=><tr key={key(r)}><td><input type="checkbox" aria-label={`${kind==='customers'?r.courierLabel:r.customerLabel} markieren`} disabled={blocked} checked={selected.includes(key(r))} onChange={e=>setSelected(s=>e.target.checked?[...s,key(r)]:s.filter(k=>k!==key(r)))}/></td><td>{kind==='customers'?r.courierLabel:r.customerLabel}</td><td>{r.relation===1?`Besonders erwünscht · Rang ${r.position}`:r.relation===0?'Gesperrt':`Bestandsstatus ${r.relation}`}</td><td>{rate(r.commission)} %</td><td>{r.description||'–'}</td><td><div className="admin-row-actions"><button className="button" disabled={blocked} onClick={()=>void edit('UPDATE',r)}>{uiText("Bearbeiten")}</button>{r.relation===1&&<button className="button" disabled={blocked} onClick={()=>void edit('MOVE',r)}>Rang ändern</button>}<button className="button" disabled={busy||uncertain} onClick={()=>void showHistory(r)}>Provisionshistorie</button><button className="button" disabled={blocked} onClick={()=>remove([r])}>Entfernen</button></div></td></tr>)}</tbody></table></div>}
{data&&!filtered.length&&<p>Keine Beziehungen gefunden.</p>}{filtered.length>100&&<div className="admin-row-actions"><button className="button" disabled={!page} onClick={()=>setPage(p=>p-1)}>Zurück</button><span>Seite {page+1} {uiText(" von ")}{Math.ceil(filtered.length/100)}</span><button className="button" disabled={(page+1)*100>=filtered.length} onClick={()=>setPage(p=>p+1)}>Weiter</button></div>}
</div></Modal>
{draft&&<Modal title={titles[draft.action]} wide onClose={()=>void closeEditor()}><div className="admin-dialog-body customer-relations"><ErrorMessage message={error}/><fieldset className="business-fieldset" disabled={busy||uncertain}>
{draft.action==='ADD'&&<><div className="admin-toolbar"><Field label={kind==='customers'?'Transporteur suchen (Name, EID oder SID)':'Kunde suchen (Name oder EID)'}><input type="search" maxLength={100} value={search} onChange={e=>{setSearch(e.target.value);setChoices([]);setSearched(false);}} onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault();void find();}}}/></Field><button className="button" onClick={()=>void find()}>{uiText("Suchen")}</button></div><div className="business-child-list">{choices.map(c=><button className="button" key={c.id} onClick={()=>void choose(c)}>{c.label}</button>)}</div>{more&&<p>Mehr als 50 Treffer. Bitte die Suche eingrenzen.</p>}{searched&&!choices.length&&<p>Keine passenden Stammdaten gefunden.</p>}</>}
{draft.label&&<p className="relation-selection">{draft.label}</p>}
{['ADD','UPDATE'].includes(draft.action)&&<><div className="form-grid cols-2"><Field label="Beziehungsart"><select value={draft.relation} onChange={e=>change({relation:Number(e.target.value)})}><option value={0}>Gesperrt</option><option value={1}>{uiText("Besonders erwünscht")}</option></select></Field><Field label="Provision (%)"><input type="number" min={0} max={100} step={0.01} disabled={!data?.canEditCommission||draft.relation!==1} value={draft.commission} onChange={e=>change({commission:e.target.value})}/></Field></div>{!data?.canEditCommission&&<p className="form-hint">Provisionen können mit dem zusätzlichen Provisionsrecht geändert werden. Der bestehende Wert bleibt erhalten.</p>}<Field label={uiText("Bemerkung")}><textarea rows={3} maxLength={255} value={draft.description} onChange={e=>change({description:e.target.value})}/></Field></>}
{draft.action==='MOVE'&&<Field label="Neuer Favoritenrang"><input type="number" min={1} max={draft.maxPosition} step={1} value={draft.position} onChange={e=>change({position:Number(e.target.value)})}/></Field>}
{draft.action==='REMOVE'&&<p>{draft.targets.length} ausgewählte Beziehungen entfernen. Die verbleibenden Favoriten werden fortlaufend nummeriert; die Provisionshistorie bleibt erhalten.</p>}
</fieldset>{preview&&<section className="customer-settings-preview"><h3>Änderungsvorschau</h3><ul>{preview.descriptions.map((d,i)=><li key={i}>{d}</li>)}</ul>{preview.commissionChanges>0&&<p>Provisionsänderungen mit Historieneintrag: {preview.commissionChanges}</p>}</section>}{uncertain&&<p>Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void closeEditor()}>Abbrechen</button>{preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':uiText("Änderung speichern")}</button>:<button className="button primary" disabled={busy||!draft.targets.length} onClick={()=>void prepare()}>Änderung prüfen</button>}</div></Modal>}
{history&&<Modal title="Kundenspezifische Provisionshistorie" wide onClose={()=>setHistory(null)}><div className="admin-dialog-body customer-relations"><p>{history.label}</p><div className="table-wrap"><table><thead><tr><th>Gültig ab</th><th>{uiText("Provision")}</th><th>Transporteur-EID</th></tr></thead><tbody>{history.rows.map((r,i)=><tr key={i}><td>{r.at}</td><td>{rate(r.value)} %</td><td>{r.eid}</td></tr>)}</tbody></table></div>{!history.rows.length&&<p>Keine historischen Provisionsstände vorhanden.</p>}</div><div className="modal-footer"><button className="button" onClick={()=>setHistory(null)}>{uiText("Schließen")}</button></div></Modal>}{discardDialog}</>;
}