Files
votianng/frontend/src/ZonePrices.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
20 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, useRef, useState, type FormEvent } from "react";
import { LoaderCircle, RefreshCw, Save, Settings2 } from "lucide-react";
import { ApiError, post, put, request } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
type Kind="POSTAL"|"AREA";
type Item={id:number;name:string};
type Query={kind:Kind;customer:number;vehicle:number;rowStart:string;columnStart:string;rowOffset:number;columnOffset:number};
type Options={customers:boolean;vehicleEnabled:boolean;vehicles:{id:number;label:string}[]};
type Cell={from:number;to:number;price:string;discount:string;courier:string;courierDiscount:string;generalPrice:string;generalDiscount:string;generalCourier:string;generalCourierDiscount:string;ambiguous:boolean;generalAmbiguous:boolean};
type Grid={rows:Item[];columns:Item[];moreRows:boolean;moreColumns:boolean;cells:Cell[];revision:string;vehicle:number};
type Draft={from:number;to:number;value:string;courier:string};
type SaveRequest={requestId:string;query:Query;revision:string;discount:boolean;cells:Draft[]};
const base="/administration/zone-prices";
const key=(c:{from:number;to:number})=>`${c.from}:${c.to}`;
const equal=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b);
const normalize=(s:string)=>{const v=s.trim().replace(",",".");return !v?"0.00":/^-?[0-9]{1,5}(\.[0-9]{1,2})?$/.test(v)?Number(v).toFixed(2):"invalid:"+s;};
const safeFailure=(e:unknown)=>e instanceof ApiError&&e.status>=400&&e.status<500;
export function ZonePrices({kind,discount,onDirty}:{kind:Kind;discount:boolean;onDirty:(value:boolean)=>void}) {
const initial:Query={kind,customer:0,vehicle:0,rowStart:"",columnStart:"",rowOffset:0,columnOffset:0};
const [query,setQuery]=useState(initial),[loaded,setLoaded]=useState<Query|null>(null),[options,setOptions]=useState<Options|null>(null),[grid,setGrid]=useState<Grid|null>(null),[draft,setDraft]=useState<Record<string,Draft>>({});
const [busy,setBusy]=useState(false),[error,setError]=useState(""),[notice,setNotice]=useState(""),[pending,setPending]=useState<SaveRequest|null>(null),[manager,setManager]=useState(false);
const [customers,setCustomers]=useState<{id:number;label:string}[]>([]),[customerSearch,setCustomerSearch]=useState(""),[searchBusy,setSearchBusy]=useState(false),[showCustomer,setShowCustomer]=useState(true),[showCourier,setShowCourier]=useState(false);
const [confirmDiscard,discardDialog]=useDiscardConfirmation(),gate=useRef(false);
const changed=grid?.cells.filter(c=>{const d=draft[key(c)];return d&&(normalize(d.value)!==normalize(discount?c.discount:c.price)||normalize(d.courier)!==normalize(discount?c.courierDiscount:c.courier));})||[];
const dirty=!!pending||changed.length>0;
useEffect(()=>{onDirty(dirty||manager);return()=>onDirty(false);},[dirty,manager,onDirty]);
function accept(g:Grid,q:Query){setGrid(g);setLoaded(q);setQuery(q);setDraft(Object.fromEntries(g.cells.map(c=>[key(c),{from:c.from,to:c.to,value:discount?c.discount:c.price,courier:discount?c.courierDiscount:c.courier}])));setPending(null);}
useEffect(()=>{let active=true;setBusy(true);request<Options>(base).then(async o=>{if(!active)return;setOptions(o);const g=await post<Grid>(base+"/query",initial);if(active)accept(g,initial);}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[]);
async function load(q:Query=query,guard=true){if(gate.current)return;if(guard&&dirty&&!await confirmDiscard(pending?"Speicherstand unklar. Entwurf verwerfen und gespeicherte Tarife laden?":"Ungespeicherte Tarifänderungen verwerfen?"))return;gate.current=true;setBusy(true);setError("");try{accept(await post<Grid>(base+"/query",q),q);setNotice("");}catch(e){setError((e as Error).message);}finally{gate.current=false;setBusy(false);}}
async function save(){if(!grid||!loaded||gate.current)return;gate.current=true;const body=pending||{requestId:crypto.randomUUID(),query:loaded,revision:grid.revision,discount,cells:changed.map(c=>draft[key(c)])};setPending(body);setBusy(true);setError("");try{await put(base,body);setPending(null);setGrid(null);setDraft({});setNotice("Tarife und Historie gespeichert.");accept(await post<Grid>(base+"/query",loaded),loaded);}catch(e){if(safeFailure(e))setPending(null);setError((e as Error).message);}finally{gate.current=false;setBusy(false);}}
async function search(e:FormEvent){e.preventDefault();setSearchBusy(true);setError("");try{setCustomers(await request<{id:number;label:string}[]>("/administration/service-prices/customers?q="+encodeURIComponent(customerSearch)));}catch(e){setError((e as Error).message);}finally{setSearchBusy(false);}}
const selected=equal(query,loaded),frozen=busy||!!pending||!selected,priceLabel=discount?"Rabatt (%)":"Preis",courierLabel=discount?"Frachtvergütungsrabatt (%)":"Frachtvergütung";
const indexed=new Map(grid?.cells.map(c=>[key(c),c]));
function update(k:string,field:"value"|"courier",value:string){setDraft(d=>({...d,[k]:{...d[k],[field]:value}}));}
return <><div className="admin-title"><div><h2>{kind==="AREA"?(discount?"Bereichsrabatte":"Bereichspreise"):(discount?"PLZ-Rabatte":"PLZ-Preise")}</h2><p>Von-/Nach-Tarife mit getrennten Kunden- und Kurierwerten.</p></div><button className="button" disabled={busy||dirty} onClick={()=>setManager(true)}><Settings2 size={16}/>{kind==="AREA"?"Bereiche und Nachbarschaften":"PLZ-Nachbarschaften"}</button></div>
{error&&<div className="alert error" role="alert">{error}</div>}{notice&&<p className="admin-success" role="status">{notice}</p>}
{options?.customers&&<form className="admin-toolbar" onSubmit={search}><Field label="Kunde suchen"><input maxLength={80} value={customerSearch} onChange={e=>setCustomerSearch(e.target.value)} placeholder="Kundennummer oder Firma"/></Field><button className="button" disabled={searchBusy}>Kunden suchen</button></form>}
<form onSubmit={e=>{e.preventDefault();void load();}}><fieldset disabled={busy||!!pending}><div className="admin-toolbar"><Field label="Tarife für"><select value={query.customer} onChange={e=>setQuery(q=>({...q,customer:Number(e.target.value)}))}><option value={0}>Allgemeine Tarife</option>{query.customer>0&&!customers.some(c=>c.id===query.customer)&&<option value={query.customer}>Kunde {query.customer}</option>}{customers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field>
{options?.vehicleEnabled&&<Field label="Fahrzeugtarif"><select value={query.vehicle} onChange={e=>setQuery(q=>({...q,vehicle:Number(e.target.value)}))}><option value={0}>Alle Fahrzeugtypen</option>{options.vehicles.filter(v=>v.id!==0).map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>}
<Field label="Zeilen ab (VON)"><input maxLength={10} value={query.rowStart} onChange={e=>setQuery(q=>({...q,rowStart:e.target.value,rowOffset:0}))} placeholder={kind==="AREA"?"Bereichsname":"PLZ"}/></Field><Field label="Spalten ab (NACH)"><input maxLength={10} value={query.columnStart} onChange={e=>setQuery(q=>({...q,columnStart:e.target.value,columnOffset:0}))} placeholder={kind==="AREA"?"Bereichsname":"PLZ"}/></Field><button className="button"><RefreshCw size={16}/>Tabelle anzeigen</button></div></fieldset></form>
{grid&&loaded&&<><p className="admin-hint">{loaded.customer?`Kunde ${loaded.customer} · Allgemeine Vergleichswerte in Klammern`:"Allgemeine Tarife"}{grid.vehicle!==loaded.vehicle?` · Zugeordneter Fahrzeugtarif: ${options?.vehicles.find(v=>v.id===grid.vehicle)?.label||grid.vehicle}`:""}. Die Richtung VON → NACH wird separat gespeichert.</p>{!selected&&<p role="status">Auswahl geändert. Bitte „Tabelle anzeigen“ wählen.</p>}
<div className="admin-toolbar"><label><input type="checkbox" checked={showCustomer} onChange={e=>{setShowCustomer(e.target.checked);if(!e.target.checked)setShowCourier(true);}}/>{discount?"Rabatte":"Preise"}</label><label><input type="checkbox" checked={showCourier} onChange={e=>{setShowCourier(e.target.checked);if(!e.target.checked)setShowCustomer(true);}}/>{discount?"Frachtvergütungsrabatte":"Frachtvergütungen"}</label></div>
<div className="admin-table-wrap service-price-wrap"><table className="admin-table service-price-table"><thead><tr><th scope="col">VON ↓ / NACH →</th>{grid.columns.map(c=><th scope="col" key={c.id}>{c.name}</th>)}</tr></thead><tbody>{grid.rows.map(r=><tr key={r.id}><th scope="row">{r.name}</th>{grid.columns.map(col=>{const k=key({from:r.id,to:col.id}),c=indexed.get(k),d=draft[k];return <td key={col.id}>{c&&d&&<><fieldset disabled={frozen||c.ambiguous}>{showCustomer&&<label><span>{priceLabel}</span><input inputMode="decimal" maxLength={10} aria-label={`${priceLabel} · ${r.name} → ${col.name}`} value={d.value} onChange={e=>update(k,"value",e.target.value)}/>{loaded.customer>0&&<small>({c.generalAmbiguous?"mehrdeutig":discount?c.generalDiscount:c.generalPrice})</small>}</label>}{showCourier&&<label className="service-courier"><span>{courierLabel}</span><input inputMode="decimal" maxLength={10} aria-label={`${courierLabel} · ${r.name} → ${col.name}`} value={d.courier} onChange={e=>update(k,"courier",e.target.value)}/>{loaded.customer>0&&<small>({c.generalAmbiguous?"mehrdeutig":discount?c.generalCourierDiscount:c.generalCourier})</small>}</label>}</fieldset>{c.ambiguous&&<small>Bestand mehrdeutig</small>}</>}</td>;})}</tr>)}</tbody></table></div>
{(!grid.rows.length||!grid.columns.length)&&<p>Für diesen Startwert sind keine Einträge vorhanden.</p>}
<div className="admin-toolbar zone-pagination"><span>Zeilen {loaded.rowOffset+1}–{loaded.rowOffset+grid.rows.length}</span><button className="button" disabled={busy||!selected||loaded.rowOffset===0} onClick={()=>void load({...loaded,rowOffset:Math.max(0,loaded.rowOffset-10)})}>Vorige Zeilen</button><button className="button" disabled={busy||!selected||!grid.moreRows} onClick={()=>void load({...loaded,rowOffset:loaded.rowOffset+10})}>Weitere Zeilen</button><span>Spalten {loaded.columnOffset+1}–{loaded.columnOffset+grid.columns.length}</span><button className="button" disabled={busy||!selected||loaded.columnOffset===0} onClick={()=>void load({...loaded,columnOffset:Math.max(0,loaded.columnOffset-10)})}>Vorige Spalten</button><button className="button" disabled={busy||!selected||!grid.moreColumns} onClick={()=>void load({...loaded,columnOffset:loaded.columnOffset+10})}>Weitere Spalten</button></div>
<div className="admin-toolbar service-save"><button className="button primary" disabled={busy||!selected||(!changed.length&&!pending)} onClick={()=>void save()}><Save size={16}/>{pending?"Speicherstatus erneut prüfen":`${changed.length} Änderungen speichern`}</button><button className="button" disabled={busy} onClick={()=>void load(loaded)}>Gespeicherten Stand laden</button></div>
</>}{busy&&<p role="status"><LoaderCircle className="spin" size={16}/> Tarife werden verarbeitet …</p>}{pending&&!busy&&<p role="status">Die Speicherung wurde nicht bestätigt. Bitte mit derselben Anfrage erneut prüfen oder den gespeicherten Stand laden.</p>}
{manager&&<ZoneManager kind={kind} onClose={()=>{setManager(false);void load(query,false);}}/>}{discardDialog}
</>;
}
type Detail={item:Item;neighbors:(Item&{symmetric:boolean})[];postals:Item[];revision:string};
type Action="CREATE"|"RENAME"|"LINK"|"UNLINK"|"NEIGHBOR_ADD"|"NEIGHBOR_REMOVE";
type Change={requestId:string;kind:Kind;id:number;revision:string;action:Action;name:string;targets:number[]};
function ZoneManager({kind,onClose}:{kind:Kind;onClose:()=>void}) {
const [items,setItems]=useState<Item[]>([]),[term,setTerm]=useState(""),[detail,setDetail]=useState<Detail|null>(null),[name,setName]=useState(""),[newName,setNewName]=useState("");
const [targetTerm,setTargetTerm]=useState(""),[targets,setTargets]=useState<Item[]>([]),[target,setTarget]=useState(0),[targetKind,setTargetKind]=useState<"postal"|"neighbor">("neighbor");
const [selectedPostals,setSelectedPostals]=useState<number[]>([]),[selectedNeighbors,setSelectedNeighbors]=useState<number[]>([]),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false);
const [review,setReview]=useState<{body:Change;description:string}|null>(null),[pending,setPending]=useState<Change|null>(null),[confirmDiscard,discardDialog]=useDiscardConfirmation(),gate=useRef(false);
const dirty=!!review||!!pending||!!newName||!!name&&name!==detail?.item.name||selectedPostals.length>0||selectedNeighbors.length>0;
const itemUrl=(k:Kind,q:string)=>base+`/items?kind=${k}&q=${encodeURIComponent(q)}`;
useEffect(()=>{let active=true;request<Item[]>(itemUrl(kind,"")).then(d=>{if(active)setItems(d);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[]);
async function close(){if(busy)return;if(!dirty||await confirmDiscard("Ungespeicherte Bereichs- oder Nachbarschaftsänderungen verwerfen?"))onClose();}
async function search(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{setItems(await request<Item[]>(itemUrl(kind,term)));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function select(id:number){if(busy)return;if(dirty&&!await confirmDiscard("Ungespeicherte Bereichs- oder Nachbarschaftsänderungen verwerfen?"))return;setBusy(true);setError("");try{const d=await request<Detail>(base+`/details?kind=${kind}&id=${id}`);setDetail(d);setName(d.item.name);setNewName("");setSelectedPostals([]);setSelectedNeighbors([]);setReview(null);setPending(null);setTargets([]);setTarget(0);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function findTarget(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{let t=targetTerm.trim();if(targetKind==="postal"&&/^\d{1,4}$/.test(t))t=t.padStart(5,"0");setTargets(await request<Item[]>(itemUrl(targetKind==="postal"?"POSTAL":kind,t)));setTarget(0);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function prepare(action:Action,description:string,ids:number[]=[],value=""){setReview({body:{requestId:crypto.randomUUID(),kind,id:detail?.item.id||0,revision:detail?.revision||"",action,name:value,targets:ids},description});setError("");setNotice("");}
async function save(){if(!review||gate.current)return;gate.current=true;setBusy(true);setError("");const body=pending||review.body;setPending(body);try{await put(base+"/management",body);setPending(null);setReview(null);setNewName("");setSelectedNeighbors([]);setSelectedPostals([]);setNotice("Änderung gespeichert.");setItems(await request<Item[]>(itemUrl(kind,term)));if(detail){const d=await request<Detail>(base+`/details?kind=${kind}&id=${detail.item.id}`);setDetail(d);setName(d.item.name);}}catch(e){if(safeFailure(e))setPending(null);setError((e as Error).message);}finally{gate.current=false;setBusy(false);}}
const toggle=(ids:number[],id:number)=>ids.includes(id)?ids.filter(v=>v!==id):[...ids,id];
return <Modal title={kind==="AREA"?"Bereiche und Nachbarschaften":"PLZ-Nachbarschaften"} onClose={()=>void close()} wide><div className="modal-body zone-manager">
{error&&<div className="alert error" role="alert">{error}</div>}{notice&&<p className="admin-success" role="status">{notice}</p>}
{review?<><p>{review.description}</p>{(review.body.action==="NEIGHBOR_ADD"||review.body.action==="NEIGHBOR_REMOVE")&&<p>Die Nachbarschaft wird in beiden Richtungen geändert. Weitere Nachbarschaften bleiben unverändert.</p>}<div className="modal-actions"><button className="button" disabled={busy||!!pending} onClick={()=>setReview(null)}>Zurück</button><button className="button primary" disabled={busy} onClick={()=>void save()}>{pending?"Speicherstatus erneut prüfen":"Änderung bestätigen"}</button></div>{error&&<button className="button" disabled={busy} onClick={()=>{setReview(null);if(detail)void select(detail.item.id);}}>Gespeicherten Stand laden</button>}</>:<fieldset disabled={busy}>
<form className="admin-toolbar" onSubmit={search}><Field label={kind==="AREA"?"Bereich suchen":"PLZ suchen"}><input value={term} maxLength={10} onChange={e=>setTerm(e.target.value)} placeholder="Anfang des Namens oder der PLZ"/></Field><button className="button">Suchen</button></form>
<Field label={kind==="AREA"?"Bereich auswählen":"PLZ auswählen"}><select value={detail?.item.id||0} onChange={e=>{if(e.target.value)void select(Number(e.target.value));}}><option value={0}>Bitte auswählen …</option>{detail&&!items.some(i=>i.id===detail.item.id)&&<option value={detail.item.id}>{detail.item.name}</option>}{items.map(i=><option key={i.id} value={i.id}>{i.name}</option>)}</select></Field>
{kind==="AREA"&&<form className="admin-toolbar" onSubmit={e=>{e.preventDefault();prepare("CREATE",`Bereich „${newName}“ anlegen?`,[],newName);}}><Field label="Neuer Bereichsname"><input value={newName} maxLength={10} required onChange={e=>setNewName(e.target.value)}/></Field><button className="button" disabled={!newName.trim()}>Bereich anlegen</button></form>}
{detail&&<>
{kind==="AREA"&&<><form className="admin-toolbar" onSubmit={e=>{e.preventDefault();prepare("RENAME",`Bereich „${detail.item.name}“ in „${name}“ umbenennen?`,[],name);}}><Field label="Bereichsname"><input value={name} maxLength={10} required onChange={e=>setName(e.target.value)}/></Field><button className="button" disabled={!name.trim()||name===detail.item.name}>Bereich umbenennen</button></form><h3>Zugehörige PLZ</h3><div className="zone-links">{detail.postals.map((p,i)=><label key={`${p.id}-${i}`}><input type="checkbox" checked={selectedPostals.includes(p.id)} onChange={()=>setSelectedPostals(ids=>toggle(ids,p.id))}/>{p.name}</label>)}{!detail.postals.length&&<p>Keine PLZ zugeordnet.</p>}</div><button className="button" disabled={!selectedPostals.length} onClick={()=>prepare("UNLINK",`${selectedPostals.length} PLZ-Verknüpfungen aus „${detail.item.name}“ entfernen?`,selectedPostals)}>Markierte PLZ-Verknüpfungen entfernen</button></>}
<h3>Nachbarschaften</h3><div className="zone-links">{detail.neighbors.map(n=><label key={n.id}><input type="checkbox" checked={selectedNeighbors.includes(n.id)} onChange={()=>setSelectedNeighbors(ids=>toggle(ids,n.id))}/>{n.name}{!n.symmetric&&<span> · bisher einseitig <button type="button" className="button" onClick={()=>prepare("NEIGHBOR_ADD",`Nachbarschaft mit „${n.name}“ in beiden Richtungen ergänzen?`,[n.id])}>Vervollständigen</button></span>}</label>)}{!detail.neighbors.length&&<p>Keine Nachbarn verknüpft.</p>}</div><button className="button" disabled={!selectedNeighbors.length} onClick={()=>prepare("NEIGHBOR_REMOVE",`${selectedNeighbors.length} Nachbarschaften von „${detail.item.name}“ entfernen?`,selectedNeighbors)}>Markierte Nachbarn entfernen</button>
<h3>Verknüpfung hinzufügen</h3><form className="admin-toolbar" onSubmit={findTarget}>{kind==="AREA"&&<Field label="Verknüpfungsart"><select value={targetKind} onChange={e=>{setTargetKind(e.target.value as "postal"|"neighbor");setTargets([]);setTarget(0);}}><option value="neighbor">Bereichsnachbar</option><option value="postal">Zugehörige PLZ</option></select></Field>}<Field label={targetKind==="postal"||kind==="POSTAL"?"PLZ für Verknüpfung":"Nachbarbereich suchen"}><input value={targetTerm} maxLength={10} onChange={e=>setTargetTerm(e.target.value)}/></Field><button className="button">Verknüpfungsziel suchen</button></form>
<div className="admin-toolbar"><Field label="Verknüpfungsziel"><select value={target} onChange={e=>setTarget(Number(e.target.value))}><option value={0}>Bitte auswählen …</option>{targets.filter(t=>targetKind==="postal"||t.id!==detail.item.id).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}</select></Field><button className="button" disabled={!target} onClick={()=>prepare(targetKind==="postal"?"LINK":"NEIGHBOR_ADD",`„${targets.find(t=>t.id===target)?.name}“ mit „${detail.item.name}“ ${targetKind==="postal"?"als PLZ verknüpfen":"als Nachbar verknüpfen"}?`,[target])}>{targetKind==="postal"?"PLZ verknüpfen":"Nachbar anlegen"}</button></div>
</>}
</fieldset>}{busy&&<p role="status">Wird verarbeitet …</p>}{discardDialog}</div>
</Modal>;
}