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.
63 lines
14 KiB
TypeScript
63 lines
14 KiB
TypeScript
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}</>;
|
||
}
|