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:
1 parent
42c87282ee
commit
363bc27c26
190 files changed
+14815
-1857
No files matched your search
@@ -0,0 +1,85 @@
|
||||
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>;
|
||||
}
|
||||
Reference in new issue
Block a user