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:
Sven committed 2026-10-13 20:31:31 +02:00
1 parent 42c87282ee
commit 363bc27c26
190 files changed
+14815 -1857

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import {useEffect,useState,type FormEvent} from 'react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {request,post,ApiError} from './api';
type Kind='POSTAL'|'AREA';
type Draft={target:string;street:string;postalCode:string;city:string;country:string;from:string;to:string;mode:string;sort:string};
type Row={id:number;target:string;street:string;postalCode:string;city:string;country:string;houseFrom:number|null;houseTo:string|null;mode:number|null;revision:string;exceptions:{house:string;area:string}[]};
type Result={state:'DONE'|'PENDING';message:string};
type Options={operations:{id:number;token:string}[]};
const blank:Draft={target:'',street:'',postalCode:'',city:'',country:'',from:'',to:'',mode:'',sort:'street'};
const labels={POSTAL:'PLZ-Adressen',AREA:'Bereichsadressen'};
export function AddressMappings({kind,onDirty}:{kind:Kind;onDirty:(v:boolean)=>void}){
const base=`/administration/addresses/${kind}`;
const [draft,setDraft]=useState(blank),[query,setQuery]=useState(blank),[page,setPage]=useState(0),[refresh,setRefresh]=useState(0),[rows,setRows]=useState<Row[]>([]),[total,setTotal]=useState(0),[options,setOptions]=useState<Options|null>(null),[selected,setSelected]=useState<number[]>([]),[editor,setEditor]=useState(false),[removing,setRemoving]=useState<Row[]|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[loading,setLoading]=useState(false),[resuming,setResuming]=useState(false);
useEffect(()=>{let live=true;setLoading(true);setSelected([]);Promise.all([request<Options>(base),post<{items:Row[];total:number}>(base+'/query',{...query,mode:query.mode===''?null:Number(query.mode),page})]).then(([o,r])=>{if(live){setOptions(o);setRows(r.items);setTotal(r.total);setError('');}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setLoading(false);});return()=>{live=false;};},[base,query,page,refresh]);
function search(e:FormEvent){e.preventDefault();setQuery({...draft});setPage(0);}
function done(r:Result){setNotice(r.message);setEditor(false);setRemoving(null);setRefresh(v=>v+1);}
async function resume(token:string){setResuming(true);try{done(await post<Result>(base+`/operations/${token}/resume`));}catch(e){setError((e as Error).message);}finally{setResuming(false);}}
return <><div className="admin-title"><div><h2>{labels[kind]}</h2><p>Straßen und Hausnummernbereiche für die Preisberechnung Ihrer Niederlassung zuordnen.</p></div><button className="button primary" onClick={()=>setEditor(true)}>Neue Zuordnung</button></div>{error&&<div className="alert error" role="alert">{error}</div>}{notice&&<p className="admin-success" role="status">{notice}</p>}{!!options?.operations.length&&<section><h3>Gesicherte, noch offene Vorgänge</h3>{options.operations.map(o=><p key={o.id}>Vorgang #{o.id} <button className="button" disabled={resuming} onClick={()=>void resume(o.token)}>Vorgang fortsetzen</button></p>)}</section>}
<form onSubmit={search}><fieldset disabled={loading}><AddressFields kind={kind} value={draft} setValue={setDraft} search/><div className="admin-toolbar"><Field label="Sortierung"><select value={draft.sort} onChange={e=>setDraft({...draft,sort:e.target.value})}>{[['street','Straße'],['target',kind==='AREA'?'Bereich':'Abrechnungs-PLZ'],['from','Hausnummer von'],['to','Hausnummer bis'],['mode','Modus'],['postalCode','Straßen-PLZ'],['city','Ort']].map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></Field><button className="button primary">Suchen</button><button className="button" type="button" onClick={()=>{setDraft(blank);setQuery(blank);setPage(0);}}>Felder zurücksetzen</button></div></fieldset></form>
<div className="admin-toolbar"><span>{total} Zuordnungen · Seite {page+1} von {Math.max(1,Math.ceil(total/100))}</span><button className="button danger" disabled={!selected.length||loading} onClick={()=>setRemoving(rows.filter(r=>selected.includes(r.id)))}>Auswahl löschen ({selected.length})</button></div>
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th><input type="checkbox" aria-label="Alle angezeigten Zuordnungen auswählen" checked={rows.length>0&&selected.length===rows.length} onChange={e=>setSelected(e.target.checked?rows.map(r=>r.id):[])}/></th><th>{kind==='AREA'?'Bereich':'Abrechnungs-PLZ'}</th><th>Straße</th><th>Von</th><th>Bis</th><th>Modus</th><th>PLZ</th><th>Ort / Land</th></tr></thead><tbody>{rows.map(r=><tr key={r.id}><td><input type="checkbox" aria-label={`Zuordnung ${r.id} auswählen`} checked={selected.includes(r.id)} onChange={e=>setSelected(e.target.checked?[...selected,r.id]:selected.filter(id=>id!==r.id))}/></td><td>{r.target}{r.exceptions.length>0?' *':''}</td><td>{r.street}{r.exceptions.map((x,i)=><small key={i}>Hausnr. {x.house} → Bereich {x.area}</small>)}</td><td>{r.houseFrom??'–'}</td><td>{r.houseTo||'–'}</td><td>{r.mode===1?'Ungerade':r.mode===2?'Gerade':'Alle'}</td><td>{r.postalCode}</td><td>{r.city} · {r.country}</td></tr>)}</tbody></table></div><div className="admin-row-actions"><button className="button" disabled={page===0||loading} onClick={()=>setPage(p=>p-1)}>Zurück</button><button className="button" disabled={(page+1)*100>=total||loading} onClick={()=>setPage(p=>p+1)}>Weiter</button></div>
{editor&&<AddressEditor kind={kind} base={base} onDirty={onDirty} onClose={()=>setEditor(false)} onSaved={done}/>}{removing&&<AddressDelete rows={removing} base={base} onDirty={onDirty} onClose={()=>setRemoving(null)} onSaved={done}/>}</>;
}
function AddressFields({kind,value,setValue,search=false}:{kind:Kind;value:Draft;setValue:(v:Draft)=>void;search?:boolean}){
function field(key:keyof Draft,label:string,max:number,required=false){return <Field label={label}><input required={required&&!search} maxLength={max} value={value[key]} onChange={e=>setValue({...value,[key]:e.target.value})}/></Field>;}
return <div className="admin-toolbar">{field('target',kind==='AREA'?'Bereich':'Abrechnungs-PLZ',10,true)}{field('street','Straße',50,true)}{field('from','Hausnummer von',4)}{field('to','Hausnummer bis',4)}<Field label="Hausnummernmodus"><select value={value.mode} onChange={e=>setValue({...value,mode:e.target.value})}>{search&&<option value="">Alle Modi</option>}<option value="0">Alle Hausnummern</option><option value="1">Nur ungerade</option><option value="2">Nur gerade</option></select></Field>{field('postalCode','Straßen-PLZ',10,true)}{field('city','Ort',50)}{field('country','Land',10)}</div>;
}
function AddressEditor({kind,base,onDirty,onClose,onSaved}:{kind:Kind;base:string;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:(r:Result)=>void}){
const initial={...blank,mode:'0',country:'DE'};const [draft,setDraft]=useState(initial),[token]=useState(()=>crypto.randomUUID()),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false),[error,setError]=useState('');const [discard,dialog]=useDiscardConfirmation();const dirty=JSON.stringify(draft)!==JSON.stringify(initial);
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard(attempted?'Dialog schließen? Ein bereits gesicherter Vorgang bleibt in der Liste zum Fortsetzen verfügbar.':'Ungespeicherte Adresszuordnung verwerfen?')))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setAttempted(true);setError('');try{onSaved(await post<Result>(base,{...draft,requestId:token,mode:Number(draft.mode)}));}catch(e){setError((e as Error).message);if(e instanceof ApiError&&[400,403,404,409].includes(e.status))setAttempted(false);}finally{setBusy(false);}}
return <><Modal title={`${labels[kind]} · Neue Zuordnung`} onClose={close}><form className="admin-dialog-body" onSubmit={save}>{error&&<div role="alert" className="alert error">{error}</div>}<fieldset disabled={busy||attempted}><AddressFields kind={kind} value={draft} setValue={setDraft}/></fieldset><p className="form-hint">Leere Hausnummerngrenzen gelten ohne Begrenzung. Der Ort wird nur bei eindeutiger vorhandener PLZ-Zuordnung ergänzt. Identische Zuordnungen werden nicht doppelt angelegt.</p><div className="admin-row-actions"><button type="button" className="button" disabled={busy} onClick={close}>Abbrechen</button><button className="button primary" disabled={busy||!dirty}>{attempted?'Denselben Vorgang erneut abrufen':'Speichern'}</button></div></form></Modal>{dialog}</>;
}
function AddressDelete({rows,base,onDirty,onClose,onSaved}:{rows:Row[];base:string;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:(r:Result)=>void}){
const [token]=useState(()=>crypto.randomUUID()),[busy,setBusy]=useState(false),[error,setError]=useState('');useEffect(()=>{onDirty(busy);return()=>onDirty(false);},[busy,onDirty]);
async function remove(){setBusy(true);setError('');try{onSaved(await post<Result>(base+'/remove',{requestId:token,items:rows.map(r=>({id:r.id,revision:r.revision}))}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title="Adresszuordnungen löschen" onClose={()=>{if(!busy)onClose();}}><div className="admin-dialog-body">{error&&<div className="alert error" role="alert">{error}</div>}<p>{rows.length} ausgewählte Zuordnungen dauerhaft entfernen? Die Straßenadressen bleiben erhalten.</p><ul>{rows.map(r=><li key={r.id}>{r.target} · {r.street} {r.houseFrom??''}–{r.houseTo??''}, {r.postalCode} {r.city}</li>)}</ul><div className="admin-row-actions"><button className="button" disabled={busy} onClick={onClose}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Zuordnungen löschen</button></div></div></Modal>;
}
+147
View File
@@ -0,0 +1,147 @@
import { createContext, useContext, useEffect, useRef, useState, type FormEvent } from "react";
import { CalendarDays, Clock3, FolderTree, FileText, Info, LoaderCircle, Plus, Save, Trash2 } from "lucide-react";
import { ZonePrices } from "./ZonePrices";
import { ServicePrices } from "./ServicePrices";
import { AddressMappings } from "./AddressMappings";
import { Filters } from "./Filters";
import { Evaluations } from "./Newsletters";
import { Reports } from "./Reports";
import { request, put, post } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
type Option = { id: string; label: string };
type Config = { 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 = "postalPrices" | "postalDiscounts" | "areaPrices" | "areaDiscounts" | "servicePrices" | "serviceDiscounts" | "postalAddresses" | "areaAddresses" | "filters" | "evaluations" | "customerReports" | "courierReports" | "holidays" | "travelTimes" | "groups" | "imprint";
const sections = [{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: "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 }: { refresh: number;onDirtyChange:(dirty:boolean)=>void }) {
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}/>{s.label}</button>)}</aside><section className="panel admin-panel" key={active}>
{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>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>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 [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>Feiertage</h2><p>Jahreskalender und Übernahme aus einer Vorlage oder einem Vorjahr.</p></div><strong>{draft.length} 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}/>Ä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>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 [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>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>PLZ</th><th>Land</th><th>Zeit in Minuten</th><th>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}/>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 [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>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)}>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 [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="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">Speichern</button></div></fieldset></form></Modal>{discardDialog}</>;
}
function GroupMembers({group,headquarters,kinds,onClose}:{group:Group;headquarters:number;kinds:Option[];onClose:()=>void}) {
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">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>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":"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>;
}
+92 -1792
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState, type FormEvent } from "react";
import { Save } from "lucide-react";
import { request, post, put } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { AppointmentNotification } from "./AppointmentNotification";
import { Lookup } from "./EntryLookup";
export type AppointmentOption = { id: number; label: string };
export type AppointmentOptions = {
headquarters: AppointmentOption[]; currentHeadquarters: number; currentUser: number; now: string;
categories: Record<string, AppointmentOption[]>;
people: (AppointmentOption & { headquarters: string; headquartersId: number })[];
groups: (AppointmentOption & { members: number[] })[];
defaultMonitoring: number; miscType: string; canReadReports: boolean;
};
export type Appointment = {
id: number; headquarters: string; author: string; text: string; startsAt: string; endsAt: string | null;
status: number; visibility: number; kind: number; monitoring: number;
customerId: number | null; customerLabel: string;
participants: (AppointmentOption & { confirmed: boolean })[];
revision: string; canEdit: boolean; canDelete: boolean; canConfirm: boolean; canFinish: boolean; hasLegacyRelation: boolean;
};
export type AppointmentSeed = { customer?: AppointmentOption; date?: string; text?: string };
export const categoryNames = ["Status", "Sichtbarkeit", "Terminart", "Kontrolle"];
export const appointmentDate = (value: string | null) => value ? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" }) : "Kein Ende";
export const categoryLabel = (options: AppointmentOptions, category: number, value: number) => options.categories[`category${category}`]?.find(o => o.id === value)?.label ?? `Unbekannt (${value})`;
export function AppointmentEditor({ appointment, seed, options: supplied, onClose, onSaved, onDirtyChange }: {
appointment?: Appointment; seed?: AppointmentSeed; options?: AppointmentOptions;
onClose: () => void; onSaved: (id: number) => void | Promise<void>; onDirtyChange: (dirty: boolean) => void;
}) {
const [options, setOptions] = useState<AppointmentOptions | null>(supplied ?? null), [error, setError] = useState("");
useEffect(() => {
if (supplied) return;
let active = true;
request<AppointmentOptions>("/appointments/options").then(o => { if (active) setOptions(o); }).catch(e => { if (active) setError(e.message); });
return () => { active = false; };
}, [supplied]);
if (!options) return <Modal title="Termin laden" onClose={onClose}><div className="admin-dialog-body">{error ? <p role="alert">{error}</p> : <p>Terminvorgaben werden geladen …</p>}</div></Modal>;
return <EditorForm appointment={appointment} seed={seed} options={options} onClose={onClose} onSaved={onSaved} onDirtyChange={onDirtyChange} />;
}
function EditorForm({ appointment: existing, seed, options, onClose, onSaved, onDirtyChange }: {
appointment?: Appointment; seed?: AppointmentSeed; options: AppointmentOptions;
onClose: () => void; onSaved: (id: number) => void | Promise<void>; onDirtyChange: (dirty: boolean) => void;
}) {
const [initial] = useState(() => {
const start = new Date(`${seed?.date || options.now.slice(0, 10)}T09:00:00`);
if (!seed?.date || seed.date === options.now.slice(0, 10)) {
const now = new Date(options.now); start.setTime(now.getTime()); start.setHours(start.getHours() + 1, 0, 0, 0);
}
const end = new Date(start); end.setHours(end.getHours() + 1);
const local = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
return {
startsAt: existing?.startsAt?.slice(0, 16) ?? local(start),
endsAt: (existing?.endsAt ?? existing?.startsAt)?.slice(0, 16) ?? local(end),
text: existing?.text ?? seed?.text ?? "", status: existing?.status ?? 1, visibility: existing?.visibility ?? 1,
kind: existing?.kind ?? 1, monitoring: existing?.monitoring ?? options.defaultMonitoring,
customerId: existing?.customerId ?? seed?.customer?.id ?? null,
participants: existing?.participants.map(p => p.id).sort((a, b) => a - b) ?? [],
};
});
const [draft, setDraft] = useState(initial);
const [notify, setNotify] = useState(false), [savedId, setSavedId] = useState<number | null>(null);
const [customer, setCustomer] = useState<AppointmentOption | null>(existing?.customerId ? { id: existing.customerId, label: existing.customerLabel } : seed?.customer ?? null);
const [search, setSearch] = useState(""), [group, setGroup] = useState("");
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [confirmMisc, setConfirmMisc] = useState(false);
const [askDiscard, discardDialog] = useDiscardConfirmation();
const changed = JSON.stringify(draft) !== JSON.stringify(initial), editable = !existing || existing.canEdit;
useEffect(() => { onDirtyChange(changed || busy); return () => onDirtyChange(false); }, [changed, busy, onDirtyChange]);
async function close() { if (!busy && (!changed || await askDiscard("Ungespeicherten Termin verwerfen?"))) onClose(); }
async function save(e?: FormEvent) {
e?.preventDefault();
if (!draft.text.trim() || !draft.startsAt || !draft.endsAt || draft.endsAt < draft.startsAt) { setError("Bitte Termintext, Beginn und ein Ende ab Beginn eingeben."); return; }
if (options.miscType && draft.kind === Number(options.miscType) && !confirmMisc) { setConfirmMisc(true); return; }
setConfirmMisc(false); setBusy(true); setError("");
try {
const body = { ...draft, revision: existing?.revision };
const result = existing ? await put<{ id: number }>(`/appointments/${existing.id}`, body) : await post<{ id: number }>("/appointments", body);
if (notify) setSavedId(result.id); else await onSaved(result.id);
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
const allPeople = [...options.people, ...(existing?.participants ?? []).filter(p => !options.people.some(o => o.id === p.id)).map(p => ({ ...p, headquarters: "Bisheriger Teilnehmer", headquartersId: 0 }))];
const shownPeople = allPeople.filter(p => (!group || options.groups.find(g => g.id === Number(group))?.members.includes(p.id)) && `${p.label} ${p.headquarters}`.toLocaleLowerCase("de-DE").includes(search.toLocaleLowerCase("de-DE")));
const setParticipants = (ids: number[]) => setDraft({ ...draft, participants: [...new Set(ids)].sort((a, b) => a - b) });
if (savedId !== null) return <AppointmentNotification id={savedId} initialKind={existing ? "CHANGED" : "NEW"} onDirtyChange={onDirtyChange} onClose={() => void onSaved(savedId)} />;
return <><Modal title={existing ? `Termin ${existing.id}${editable ? " bearbeiten" : " ansehen"}` : "Neuer Termin"} onClose={() => void close()} wide><form className="admin-dialog-body" onSubmit={save}>
{error && <div role="alert" className="alert error">{error}</div>}
{existing && <p>{existing.author} · {existing.headquarters} · {existing.participants.filter(p => p.confirmed).length} von {existing.participants.length} Teilnahmen bestätigt</p>}
<fieldset disabled={busy || !editable}>
<div className="appointment-form-grid"><Field label="Beginn"><input required type="datetime-local" value={draft.startsAt} onInput={e => setDraft({ ...draft, startsAt: e.currentTarget.value })} /></Field><Field label="Ende"><input required type="datetime-local" min={draft.startsAt} value={draft.endsAt} onInput={e => setDraft({ ...draft, endsAt: e.currentTarget.value })} /></Field>
{(["status", "visibility", "kind", "monitoring"] as const).map((key, index) => <Field key={key} label={categoryNames[index]}><select value={draft[key]} onChange={e => setDraft({ ...draft, [key]: Number(e.target.value) })}>{options.categories[`category${index + 1}`].filter(o => existing || key !== "monitoring" || o.id !== 2).map(o => <option key={o.id} value={o.id}>{o.label}</option>)}{!options.categories[`category${index + 1}`].some(o => o.id === draft[key]) && <option value={draft[key]}>Unbekannt ({draft[key]}) – bitte neu wählen</option>}</select></Field>)}
</div>
<Lookup<AppointmentOption> label="Kunde / Interessent" endpoint="/appointments/customers" value={customer} describe={c => c.label} onSelect={c => { setCustomer(c); setDraft({ ...draft, customerId: c?.id ?? null }); }} disabled={busy || !editable || existing?.hasLegacyRelation} />
{existing?.hasLegacyRelation && <p className="admin-hint">Die Kundenzuordnung ist durch eine vorhandene Kontakt- oder Kostenstellenverknüpfung festgelegt.</p>}
<Field label="Termintext"><textarea required rows={5} maxLength={65535} value={draft.text} onChange={e => setDraft({ ...draft, text: e.target.value })} /></Field>
<section className="appointment-participants"><h3>Teilnehmer · {draft.participants.length} ausgewählt</h3>
<div className="admin-toolbar"><Field label="Teilnehmer suchen"><input type="search" value={search} onChange={e => setSearch(e.target.value)} /></Field><Field label="Teilnehmergruppe"><select value={group} onChange={e => setGroup(e.target.value)}><option value="">Alle Gruppen</option>{options.groups.map(g => <option key={g.id} value={g.id}>{g.label}</option>)}</select></Field><button className="button" type="button" onClick={() => setParticipants([...draft.participants, ...shownPeople.map(p => p.id)])}>Angezeigte auswählen</button><button className="button" type="button" onClick={() => setParticipants(draft.participants.filter(id => !shownPeople.some(p => p.id === id)))}>Angezeigte abwählen</button></div>
<div className="appointment-people">{shownPeople.map(p => <label className="admin-check" key={p.id}><input type="checkbox" checked={draft.participants.includes(p.id)} onChange={e => setParticipants(e.target.checked ? [...draft.participants, p.id] : draft.participants.filter(id => id !== p.id))} /><span>{p.label}<small>{p.headquarters}{existing?.participants.find(o => o.id === p.id)?.confirmed ? " · Bestätigt" : ""}</small></span></label>)}{!shownPeople.length && <p>Keine Teilnehmer für diesen Filter.</p>}</div>
<p className="admin-hint">Änderungen der Terminzeit setzen die Teilnahmebestätigungen zurück.</p>
</section>
<label className="admin-check"><input type="checkbox" checked={notify} onChange={e => setNotify(e.target.checked)} />Nach dem Speichern E-Mail-Benachrichtigung vorbereiten</label>
</fieldset>
<div className="admin-row-actions"><button className="button" type="button" disabled={busy} onClick={() => void close()}>{editable ? "Abbrechen" : "Schließen"}</button>{editable && <button className="button primary" disabled={busy || !draft.text.trim() || !!existing && !changed}><Save size={16} />Termin speichern</button>}</div>
</form></Modal>{discardDialog}{confirmMisc && <Modal title="Terminart bestätigen" onClose={() => setConfirmMisc(false)}><div className="admin-dialog-body"><p>Den Termin mit der Art „{categoryLabel(options, 3, draft.kind)}“ speichern?</p><div className="admin-row-actions"><button className="button" onClick={() => setConfirmMisc(false)}>Zurück</button><button className="button primary" onClick={() => void save()}>Mit dieser Art speichern</button></div></div></Modal>}</>;
}
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
import { post } from "./api";
import { Field, Modal } from "./FormElements";
type Preview = { token: string; from: string; replyTo: string; recipients: string[]; missing: string[]; subject: string; body: string };
export function AppointmentNotification({ id, initialKind = "CHANGED", onClose, onDirtyChange }: { id: number; initialKind?: "NEW" | "CHANGED" | "CANCELLED"; onClose: () => void; onDirtyChange: (dirty: boolean) => void }) {
const [draft, setDraft] = useState({ kind: initialKind, participants: true, external: "", salutation: "Sehr geehrte Damen und Herren,", greeting: "Mit freundlichem Gruß!" });
const [preview, setPreview] = useState<Preview | null>(null), [result, setResult] = useState<{ sent: boolean; message: string } | null>(null), [busy, setBusy] = useState(false), [error, setError] = useState("");
const changed = draft.kind !== initialKind || !draft.participants || !!draft.external || draft.salutation !== "Sehr geehrte Damen und Herren," || draft.greeting !== "Mit freundlichem Gruß!";
useEffect(() => { onDirtyChange(busy || !result && (changed || !!preview)); return () => onDirtyChange(false); }, [busy, changed, preview, result, onDirtyChange]);
async function prepare() { setBusy(true); setError(""); try { setPreview(await post<Preview>(`/appointments/${id}/notifications/preview`, draft)); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
async function send() { if (!preview) return; setBusy(true); setError(""); try { setResult(await post(`/appointments/notifications/${preview.token}/send`)); } catch (e) { setError(`${(e as Error).message} Der Termin bleibt gespeichert. Bei unklarer Zustellung nur dieselbe Vorschau erneut bestätigen.`); } finally { setBusy(false); } }
return <Modal title={`E-Mail zu Termin ${id}`} onClose={() => { if (!busy) onClose(); }} wide><div className="admin-dialog-body"><p>Der Termin ist gespeichert. Der Versand erfolgt nach Prüfung der Vorschau.</p>{error && <p role="alert" className="alert error">{error}</p>}{result ? <><p role="status" className={result.sent ? "admin-success" : "alert error"}>{result.message}</p><button className="button primary" onClick={onClose}>Schließen</button></> : <><fieldset disabled={busy}>{!preview ? <><Field label="Benachrichtigungsart"><select value={draft.kind} onChange={e => setDraft({ ...draft, kind: e.target.value as typeof draft.kind })}><option value="NEW">Neuer Termin</option><option value="CHANGED">Termin geändert</option><option value="CANCELLED">Termin abgesagt</option></select></Field><label className="admin-check"><input type="checkbox" checked={draft.participants} onChange={e => setDraft({ ...draft, participants: e.target.checked })} />Teilnehmer benachrichtigen und Kopie an mich</label><Field label="Zusätzliche E-Mail-Adressen"><input value={draft.external} maxLength={2000} placeholder="name@firma.de, weitere@firma.de" onChange={e => setDraft({ ...draft, external: e.target.value })} /></Field><Field label="Anrede"><textarea value={draft.salutation} maxLength={1000} onChange={e => setDraft({ ...draft, salutation: e.target.value })} /></Field><Field label="Grußformel"><textarea value={draft.greeting} maxLength={1000} onChange={e => setDraft({ ...draft, greeting: e.target.value })} /></Field><button className="button primary" onClick={() => void prepare()}>Versandvorschau</button></> : <><dl><div><dt>Von</dt><dd>{preview.from}</dd></div><div><dt>Antwort an</dt><dd>{preview.replyTo}</dd></div><div><dt>Empfänger (BCC)</dt><dd>{preview.recipients.join(", ")}</dd></div><div><dt>Betreff</dt><dd>{preview.subject}</dd></div></dl>{preview.missing.length > 0 && <p className="alert error">Ohne gültige E-Mail-Adresse: {preview.missing.join(", ")}. Diese Teilnehmer erhalten keine Nachricht.</p>}<pre className="crm-mail-preview">{preview.body}</pre><p>Eine Absage per E-Mail löscht den Kalendereintrag nicht. Die Vorschau ist 15 Minuten gültig.</p><div className="admin-row-actions"><button className="button" onClick={() => setPreview(null)}>Vorschau ändern</button><button className="button primary" onClick={() => void send()}>E-Mail jetzt senden</button></div></>}</fieldset><button className="button" disabled={busy} onClick={onClose}>Ohne Versand schließen</button></>}</div></Modal>;
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from "react";
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { post, request } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { Lookup } from "./EntryLookup";
import { AppointmentEditor, appointmentDate, categoryLabel, categoryNames, type Appointment, type AppointmentOptions, type AppointmentOption } from "./AppointmentForm";
import { AppointmentNotification } from "./AppointmentNotification";
import { Reports } from "./Reports";
type View = "DAY" | "WEEK" | "MONTH" | "YEAR" | "STATISTICS";
type Query = { from: string; to: string; headquarters: number[]; personId: number | null; customerId: number | null; status: number | null; kind: number | null; monitoring: number | null; visibility: string[]; search: string };
type Result = { items: Appointment[]; total: number; from: string; to: string };
type Statistics = { total: number; from: string; to: string; categories: { category: number; items: { id: number; label: string; total: number }[] }[] };
const views: Record<View, string> = { DAY: "Tag", WEEK: "Woche", MONTH: "Monat", YEAR: "Jahr", STATISTICS: "Terminstatistik" };
const visibilityLabels: Record<string, string> = { OWN_PRIVATE: "Eigene · nur für mich", OWN_PARTICIPANTS: "Eigene · für Teilnehmer", OWN_PUBLIC: "Eigene · für alle", INVITED: "Ich bin Teilnehmer", OTHER_PUBLIC: "Weitere öffentliche Termine" };
// Calendar arithmetic uses wall-clock dates. No conversion to UTC, including at DST boundaries.
const day = (value: string) => new Date(`${value}T12:00:00`);
const stamp = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
const addDays = (value: string, count: number) => { const d = day(value); d.setDate(d.getDate() + count); return stamp(d); };
function period(date: string, view: View): { from: string; to: string } {
const d = day(date), year = d.getFullYear(), month = d.getMonth();
if (view === "WEEK") { const from = addDays(date, -(d.getDay() + 6) % 7); return { from, to: addDays(from, 6) }; }
if (view === "MONTH") return { from: stamp(new Date(year, month, 1, 12)), to: stamp(new Date(year, month + 1, 0, 12)) };
if (view === "YEAR") return { from: `${year}-01-01`, to: `${year}-12-31` };
return { from: date, to: date };
}
const onDay = (items: Appointment[], date: string) => items.filter(a => a.startsAt?.slice(0, 10) <= date && (a.endsAt && a.endsAt >= a.startsAt ? a.endsAt : a.startsAt).slice(0, 10) >= date);
const shortDate = (date: string) => day(date).toLocaleDateString("de-DE", { weekday: "short", day: "numeric", month: "short" });
export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDirtyChange: (dirty: boolean) => void }) {
const [options, setOptions] = useState<AppointmentOptions | null>(null), [query, setQuery] = useState<Query | null>(null);
const [view, setView] = useState<View>("DAY"), [date, setDate] = useState("");
const [customer, setCustomer] = useState<AppointmentOption | null>(null), [applied, setApplied] = useState<Query | null>(null);
const [data, setData] = useState<Result | null>(null), [stats, setStats] = useState<Statistics | null>(null);
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [notice, setNotice] = useState("");
const [editor, setEditor] = useState<Appointment | "new" | null>(null), [details, setDetails] = useState<Appointment | null>(null);
const [action, setAction] = useState<{ appointment: Appointment; kind: "delete" | "confirm" | "finish" } | null>(null);
const [resubmit, setResubmit] = useState<Appointment | null>(null), [reports, setReports] = useState<{ customer?: AppointmentOption } | null>(null);
const [reportDirty, setReportDirty] = useState(false), [askDiscard, discardDialog] = useDiscardConfirmation();
const [mail, setMail] = useState<Appointment | null>(null);
const generation = useRef(0);
useEffect(() => {
const token = ++generation.current;
setBusy(true); setError(""); setNotice(""); setMail(null); setEditor(null); setResubmit(null); setDetails(null); setAction(null); setReports(null); setReportDirty(false); onDirtyChange(false);
request<AppointmentOptions>("/appointments/options").then(async config => {
if (token !== generation.current) return;
setOptions(config); const today = config.now.slice(0, 10);
const q: Query = { from: today, to: today, headquarters: [config.currentHeadquarters], personId: config.currentUser, customerId: null, status: null, kind: null, monitoring: null, visibility: Object.keys(visibilityLabels), search: "" };
setDate(today); setView("DAY"); setQuery(q); setCustomer(null); setData(null); setStats(null);
const result = await post<Result>("/appointments/query", q);
if (token === generation.current) { setData(result); setApplied(q); }
}).catch(e => { if (token === generation.current) setError(e.message); }).finally(() => { if (token === generation.current) setBusy(false); });
return () => { generation.current++; };
}, [refresh, onDirtyChange]);
useEffect(() => { if (reports) onDirtyChange(reportDirty); }, [reportDirty, reports, onDirtyChange]);
async function load(q: Query, nextView = view) {
const token = ++generation.current; setBusy(true); setError("");
try {
const result = await post<Result | Statistics>(`/appointments/${nextView === "STATISTICS" ? "statistics" : "query"}`, q);
if (token !== generation.current) return;
if (nextView === "STATISTICS") { setStats(result as Statistics); setData(null); } else { setData(result as Result); setStats(null); }
setApplied(q);
} catch (e) { if (token === generation.current) { setError((e as Error).message); setData(null); setStats(null); } }
finally { if (token === generation.current) setBusy(false); }
}
function navigate(nextView: View, nextDate = date) {
if (!query || !nextDate) return;
const range = nextView === "STATISTICS" ? (view === "STATISTICS" ? { from: query.from, to: query.to } : { from: `${nextDate.slice(0, 4)}-01-01`, to: nextDate }) : period(nextDate, nextView);
const q = { ...query, ...range, customerId: customer?.id ?? null };
setView(nextView); setDate(nextDate); setQuery(q); void load(q, nextView);
}
function shift(direction: number) {
const d = day(date);
if (view === "MONTH") { d.setDate(1); d.setMonth(d.getMonth() + direction); }
else if (view === "YEAR") { d.setDate(1); d.setMonth(0); d.setFullYear(d.getFullYear() + direction); }
else d.setDate(d.getDate() + direction * (view === "WEEK" ? 7 : 1));
navigate(view, stamp(d));
}
async function saved(id: number) {
setEditor(null); setResubmit(null); onDirtyChange(false); setNotice(`Termin ${id} gespeichert.`);
if (applied) await load(applied);
}
async function performAction() {
if (!action) return; setBusy(true); setError("");
const { appointment: a, kind } = action;
try {
if (kind === "delete") await request(`/appointments/${a.id}`, { method: "DELETE", body: JSON.stringify({ revision: a.revision }) });
else await post(`/appointments/${a.id}/${kind}`, { revision: a.revision });
setAction(null); setDetails(null); setNotice(kind === "delete" ? `Termin ${a.id} gelöscht.` : kind === "confirm" ? "Ihre Teilnahme ist bestätigt." : `Termin ${a.id} erledigt.`);
if (applied) await load(applied);
} catch (e) { setError((e as Error).message); setAction(null); setDetails(null); } finally { setBusy(false); }
}
async function closeReports() {
if (!reportDirty || await askDiscard("Ungespeicherte Änderungen verwerfen und Kundenberichte schließen?")) { setReports(null); setReportDirty(false); onDirtyChange(false); if (applied) await load(applied); }
}
const pending = query && applied && JSON.stringify({ ...query, customerId: customer?.id ?? null }) !== JSON.stringify(applied);
return <section className="panel appointment-panel">
<div className="admin-title"><div><h2>Vertrieb · Termine</h2><p>Kalender, Teilnehmer und Wiedervorlagen für Ihre Kundenkontakte.</p></div><div className="admin-row-actions">{options?.canReadReports && <button className="button" disabled={busy} onClick={() => setReports({})}>Kundenberichte</button>}<button className="button primary" disabled={busy || !options} onClick={() => setEditor("new")}><Plus size={16} />Neuer Termin</button></div></div>
{error && <div role="alert" className="alert error">{error}</div>}{notice && <p role="status" className="admin-success">{notice}</p>}
{options && query && <><div className="appointment-tabs" aria-label="Kalenderansicht">{Object.entries(views).map(([key, label]) => <button className={`button ${view === key ? "primary" : ""}`} aria-pressed={view === key} key={key} disabled={busy} onClick={() => navigate(key as View)}>{label}</button>)}</div>
<form onSubmit={e => { e.preventDefault(); const q = { ...query, customerId: customer?.id ?? null }; setQuery(q); void load(q); }}><fieldset disabled={busy}>
<div className="admin-toolbar appointment-period">{view !== "STATISTICS" ? <><button type="button" className="icon-button" aria-label="Vorheriger Zeitraum" onClick={() => shift(-1)}><ChevronLeft /></button><Field label="Kalenderdatum"><input type="date" required min="1000-01-01" max="9998-12-31" value={date} onInput={e => { if (e.currentTarget.value) navigate(view, e.currentTarget.value); }} /></Field><button type="button" className="icon-button" aria-label="Nächster Zeitraum" onClick={() => shift(1)}><ChevronRight /></button><button type="button" className="button" onClick={() => navigate(view, options.now.slice(0, 10))}>Heute</button></> : <><Field label="Statistik von"><input required type="date" value={query.from} onInput={e => setQuery({ ...query, from: e.currentTarget.value })} /></Field><Field label="Statistik bis"><input required type="date" value={query.to} onInput={e => setQuery({ ...query, to: e.currentTarget.value })} /></Field></>}
<Field label="Ersteller / Teilnehmer"><select value={query.personId ?? ""} onChange={e => setQuery({ ...query, personId: e.target.value ? Number(e.target.value) : null })}><option value="">Alle Mitarbeiter</option>{options.people.map(p => <option key={p.id} value={p.id}>{p.label} · {p.headquarters}</option>)}</select></Field><button className="button primary" disabled={!query.headquarters.length || !query.visibility.length}>{view === "STATISTICS" ? "Statistik anzeigen" : "Termine anzeigen"}</button>
</div>
<details className="appointment-filter-details"><summary>Filter · Niederlassungen, Kategorien und Sichtbarkeit</summary><div className="appointment-filters">
<div className="report-headquarters">{options.headquarters.map(h => <label className="admin-check" key={h.id}><input type="checkbox" checked={query.headquarters.includes(h.id)} onChange={e => setQuery({ ...query, headquarters: e.target.checked ? [...query.headquarters, h.id] : query.headquarters.filter(id => id !== h.id) })} />{h.label}</label>)}{options.headquarters.length > 1 && <button className="button" type="button" onClick={() => setQuery({ ...query, headquarters: options.headquarters.map(h => h.id) })}>Alle Niederlassungen</button>}</div>
<div className="admin-toolbar">{(["status", "kind", "monitoring"] as const).map((key, i) => { const category = [1, 3, 4][i]; return <Field key={key} label={`${categoryNames[category - 1]} filtern`}><select value={query[key] ?? ""} onChange={e => setQuery({ ...query, [key]: e.target.value === "" ? null : Number(e.target.value) })}><option value="">Alle</option>{options.categories[`category${category}`].map(o => <option key={o.id} value={o.id}>{o.label}</option>)}</select></Field>; })}<Field label="Termintext suchen"><input type="search" maxLength={200} value={query.search} onChange={e => setQuery({ ...query, search: e.target.value })} /></Field></div>
<Lookup<AppointmentOption> label="Kunde filtern" endpoint="/appointments/customers" value={customer} describe={c => c.label} onSelect={setCustomer} disabled={busy} />
<div className="appointment-visibility">{Object.entries(visibilityLabels).map(([key, label]) => <label className="admin-check" key={key}><input type="checkbox" checked={query.visibility.includes(key)} onChange={e => setQuery({ ...query, visibility: e.target.checked ? [...query.visibility, key] : query.visibility.filter(v => v !== key) })} />{label}</label>)}</div>
</div></details>
</fieldset></form>
{pending && <p className="admin-hint">Filter geändert. „{view === "STATISTICS" ? "Statistik anzeigen" : "Termine anzeigen"}“ übernimmt die Auswahl.</p>}
{busy && <p role="status">Termine werden geladen …</p>}
{data && <><div className="admin-title"><h3>{view === "DAY" ? day(data.from).toLocaleDateString("de-DE", { dateStyle: "full" }) : `${shortDate(data.from)} – ${shortDate(data.to)} ${data.to.slice(0, 4)}`}</h3><span>{data.total} {data.total === 1 ? "Termin" : "Termine"}</span></div>
{view === "DAY" && <Agenda items={data.items} options={options} onSelect={setDetails} />}
{view === "WEEK" && <div className="appointment-week">{Array.from({ length: 7 }, (_, i) => addDays(data.from, i)).map(d => <section key={d}><button className="appointment-day-title" disabled={busy} onClick={() => navigate("DAY", d)}>{shortDate(d)}</button><Agenda items={onDay(data.items, d)} options={options} onSelect={setDetails} compact /></section>)}</div>}
{view === "MONTH" && <Month date={data.from} items={data.items} busy={busy} onDay={d => navigate("DAY", d)} onSelect={setDetails} />}
{view === "YEAR" && <div className="appointment-year">{Array.from({ length: 12 }, (_, i) => `${data.from.slice(0, 4)}-${String(i + 1).padStart(2, "0")}-01`).map(d => <section key={d}><button className="appointment-day-title" disabled={busy} onClick={() => navigate("MONTH", d)}>{day(d).toLocaleDateString("de-DE", { month: "long" })}</button><Month date={d} items={data.items} busy={busy} onDay={d => navigate("DAY", d)} onSelect={setDetails} mini /></section>)}</div>}
</>}
{stats && <><div className="admin-title"><h3>Terminstatistik · {stats.total} {stats.total === 1 ? "Termin" : "Termine"}</h3><span>{stats.from} bis {stats.to}</span></div><p className="admin-hint">Jeder Termin zählt einmal, auch bei mehreren Tagen oder Teilnehmern.</p><div className="appointment-statistics">{stats.categories.map(c => <section key={c.category}><h3>{categoryNames[c.category - 1]}</h3><table className="admin-table"><thead><tr><th>Kategorie</th><th>Anzahl</th></tr></thead><tbody>{c.items.map(i => <tr key={i.id}><td>{i.label}</td><td>{i.total}</td></tr>)}</tbody></table></section>)}</div></>}
</>}
{mail && <AppointmentNotification id={mail.id} onDirtyChange={onDirtyChange} onClose={() => { setMail(null); onDirtyChange(false); }} />}
{editor && options && <AppointmentEditor appointment={editor === "new" ? undefined : editor} seed={{ date }} options={options} onDirtyChange={onDirtyChange} onClose={() => setEditor(null)} onSaved={saved} />}
{details && options && <Modal title={`Termin ${details.id}`} onClose={() => setDetails(null)} wide><div className="admin-dialog-body"><p>{appointmentDate(details.startsAt)} – {appointmentDate(details.endsAt)}</p><div className="report-text">{details.text}</div><p>{details.customerLabel || "Ohne Kundenzuordnung"}</p><p>{details.author} · {details.headquarters}</p><div className="appointment-tags">{[details.status, details.visibility, details.kind, details.monitoring].map((value, i) => <span key={i}>{categoryLabel(options, i + 1, value)}</span>)}</div><h3>Teilnehmer</h3><ul>{details.participants.map(p => <li key={p.id}>{p.label} · {p.confirmed ? "Bestätigt" : "Noch nicht bestätigt"}</li>)}</ul>{!details.participants.length && <p>Keine Teilnehmer zugeordnet.</p>}<div className="admin-row-actions"><button className="button" onClick={() => { setEditor(details); setDetails(null); }}>{details.canEdit ? "Bearbeiten" : "Ansehen"}</button><button className="button" onClick={() => { setResubmit(details); setDetails(null); }}>Wiedervorlage</button>{details.canConfirm && <button className="button" onClick={() => setAction({ appointment: details, kind: "confirm" })}>Teilnahme bestätigen</button>}{details.canFinish && <button className="button" onClick={() => setAction({ appointment: details, kind: "finish" })}>Termin erledigen</button>}{options.canReadReports && details.customerId && <button className="button" onClick={() => { setReports({ customer: { id: details.customerId!, label: details.customerLabel } }); setDetails(null); }}>Kundenberichte öffnen</button>}{details.canEdit && <button className="button" onClick={() => { setMail(details); setDetails(null); }}>E-Mail-Benachrichtigung</button>}{details.canDelete && <button className="button danger" onClick={() => setAction({ appointment: details, kind: "delete" })}>Löschen</button>}</div></div></Modal>}
{action && <Modal title={action.kind === "delete" ? "Termin löschen" : action.kind === "confirm" ? "Teilnahme bestätigen" : "Termin erledigen"} onClose={() => { if (!busy) setAction(null); }}><div className="admin-dialog-body"><p>{action.kind === "delete" ? `Termin ${action.appointment.id} endgültig löschen?` : action.kind === "confirm" ? `Ihre Teilnahme an Termin ${action.appointment.id} bestätigen?` : `Die Überwachung für Termin ${action.appointment.id} abschließen?`}</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={() => setAction(null)}>Abbrechen</button><button className={`button ${action.kind === "delete" ? "danger" : "primary"}`} disabled={busy} onClick={() => void performAction()}>Bestätigen</button></div></div></Modal>}
{resubmit && <Resubmission appointment={resubmit} onClose={() => setResubmit(null)} onSaved={saved} onDirtyChange={onDirtyChange} />}
{reports && <Modal title="Kundenberichte im Vertrieb" onClose={() => void closeReports()} wide><div className="admin-dialog-body"><Reports kind="CUSTOMER" refresh={0} initialObject={reports.customer} onDirtyChange={setReportDirty} /></div></Modal>}{discardDialog}
</section>;
}
function Agenda({ items, options, onSelect, compact = false }: { items: Appointment[]; options: AppointmentOptions; onSelect: (a: Appointment) => void; compact?: boolean }) {
return <div className={`appointment-agenda ${compact ? "compact" : ""}`}>{items.map(a => <button key={a.id} className={`appointment-card monitor-${a.monitoring}`} onClick={() => onSelect(a)}><span className="appointment-card-time">{a.endsAt && a.startsAt?.slice(0, 10) !== a.endsAt.slice(0, 10) ? `${appointmentDate(a.startsAt)} – ${appointmentDate(a.endsAt)}` : `${a.startsAt?.slice(11, 16)} – ${a.endsAt?.slice(11, 16) ?? "offen"}`}</span><strong>{a.text}</strong>{a.customerLabel && <span>{a.customerLabel}</span>}<small>{a.author} · {a.headquarters}</small>{!compact && <div className="appointment-tags"><span>{categoryLabel(options, 3, a.kind)}</span><span>{categoryLabel(options, 4, a.monitoring)}</span><span>{a.participants.length} Teilnehmer</span></div>}</button>)}{!items.length && <p className="appointment-empty"><CalendarDays size={20} />Keine Termine</p>}</div>;
}
function Month({ date, items, onDay: selectDay, onSelect, busy, mini = false }: { date: string; items: Appointment[]; onDay: (date: string) => void; onSelect: (a: Appointment) => void; busy: boolean; mini?: boolean }) {
const start = period(date, "MONTH").from, offset = (day(start).getDay() + 6) % 7;
const dates = Array.from({ length: 42 }, (_, i) => addDays(start, i - offset));
return <div className={`appointment-month ${mini ? "mini" : ""}`}>{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => <span className="appointment-weekday" key={d}>{d}</span>)}{dates.map(d => {
if (d.slice(0, 7) !== start.slice(0, 7)) return <div key={d} className="appointment-calendar-gap" />;
const appointments = onDay(items, d);
return <div key={d} className={`appointment-date ${appointments.length ? "has-events" : ""}`}><button disabled={busy} className="appointment-day-title" aria-label={`${shortDate(d)}: ${appointments.length} ${appointments.length === 1 ? "Termin" : "Termine"}`} onClick={() => selectDay(d)}>{Number(d.slice(-2))}{appointments.length > 0 && <small>{appointments.length}</small>}</button>{!mini && appointments.slice(0, 3).map(a => <button key={a.id} className="appointment-month-event" onClick={() => onSelect(a)}>{a.startsAt?.slice(0, 10) === d ? a.startsAt.slice(11, 16) : "Fortsetzung ·"} {a.text}</button>)}{!mini && appointments.length > 3 && <button className="appointment-month-event" onClick={() => selectDay(d)}>+ {appointments.length - 3} weitere</button>}</div>;
})}</div>;
}
function Resubmission({ appointment: a, onClose, onSaved, onDirtyChange }: { appointment: Appointment; onClose: () => void; onSaved: (id: number) => Promise<void>; onDirtyChange: (dirty: boolean) => void }) {
const [starts, setStarts] = useState(""), [ends, setEnds] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState("");
const [askDiscard, discardDialog] = useDiscardConfirmation();
const dirty = !!starts || !!ends || busy;
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
async function close() { if (!busy && (!dirty || await askDiscard("Ungespeicherte Wiedervorlage verwerfen?"))) onClose(); }
async function save() {
setBusy(true); setError("");
try { const result = await post<{ id: number }>(`/appointments/${a.id}/resubmit`, { revision: a.revision, startsAt: starts, endsAt: ends }); await onSaved(result.id); }
catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return <><Modal title={`Wiedervorlage zu Termin ${a.id}`} onClose={() => void close()}><form className="admin-dialog-body" onSubmit={e => { e.preventDefault(); void save(); }}>{error && <p className="alert error" role="alert">{error}</p>}<p>Neuen Termin mit gleichem Text, Kunde und Teilnehmern anlegen. Der bisherige Termin bleibt erhalten; Teilnahmebestätigungen werden neu eingeholt. Ein erledigter Termin wird wieder zur Überwachung aktiviert.</p><fieldset disabled={busy}><Field label="Neuer Beginn"><input required type="datetime-local" value={starts} onInput={e => setStarts(e.currentTarget.value)} /></Field><Field label="Neues Ende"><input required type="datetime-local" min={starts} value={ends} onInput={e => setEnds(e.currentTarget.value)} /></Field><div className="admin-row-actions"><button type="button" className="button" onClick={() => void close()}>Abbrechen</button><button className="button primary">Wiedervorlage anlegen</button></div></fieldset></form></Modal>{discardDialog}</>;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Field } from "./FormElements";
import { request, post, downloadBusinessFile } from "./api";
import { ErrorMessage, uncertainWrite, type Choice } from "./BusinessForms";
type Query={from:string;to:string;mode:string;customer:number|null;center:number|null;descendants:boolean;courier:number|null;invoice:string;unassigned:boolean};
type Row={id:number;center:number;centerName:string;customer:string;courier:string;finished:string;invoice:string;invoiceDate:string;net:number;tax:number;gross:number;incomplete:boolean;exported:boolean;storno:number;description:string;paymentTerm?:string;components?:{name:string;net:number}[]};
type Result={rows:Row[];revision:string;net:number;tax:number;gross:number;pending:{token:string}[]};
const today=()=>new Date().toLocaleDateString("sv-SE");const euro=(v:number)=>new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(v);
export function Billing({refresh,onDirtyChange}:{refresh:number;onDirtyChange:(value:boolean)=>void}){
const [options,setOptions]=useState<{customers:Choice[];couriers:Choice[];canAssign:boolean}|null>(null),[centers,setCenters]=useState<Choice[]>([]),[query,setQuery]=useState<Query>({from:today().slice(0,8)+"01",to:today(),mode:"CUSTOMER",customer:null,center:null,descendants:true,courier:null,invoice:"",unassigned:false}),[loadedQuery,setLoadedQuery]=useState<Query|null>(null),[data,setData]=useState<Result|null>(null),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[selected,setSelected]=useState<number[]>([]),[number,setNumber]=useState(""),[date,setDate]=useState(today()),[details,setDetails]=useState(false);
const pending=useRef<unknown>(null);const dirty=selected.length>0||number!=="";const filtersChanged=!!loadedQuery&&JSON.stringify(query)!==JSON.stringify(loadedQuery);
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
useEffect(()=>{request<typeof options>("/billing/options").then(setOptions).catch(e=>setError(e.message));},[refresh]);
useEffect(()=>{let live=true;setCenters([]);if(query.customer)request<Choice[]>(`/billing/centers?customer=${query.customer}`).then(c=>{if(live)setCenters(c);}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[query.customer]);
async function load(e?:FormEvent){e?.preventDefault();setBusy(true);setError("");try{const d=await post<Result>("/billing/query",query);setData(d);setLoadedQuery({...query});setSelected([]);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function assign(e:FormEvent){e.preventDefault();if(!data||!loadedQuery)return;setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),query:loadedQuery,revision:data.revision,orders:selected,number,date};try{await post("/billing/assign",pending.current);pending.current=null;setUncertain(false);setNumber("");setSelected([]);setNotice("Rechnungsangaben zugeordnet.");await load();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+" Bereits begonnene Zuordnungen können nach dem Neuladen unter Offene Vorgänge fortgesetzt werden.");}finally{setBusy(false);}}
async function resume(token:string,stop=false){setBusy(true);setError("");try{await post(`/billing/operations/${token}/${stop?"stop":"resume"}`);pending.current=null;setUncertain(false);setNotice(stop?"Vorgang beendet. Bereits gespeicherte Zuordnungen bleiben erhalten.":"Rechnungszuordnung abgeschlossen.");await load();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function download(pdf:boolean){if(!loadedQuery)return;setBusy(true);setError("");try{await downloadBusinessFile(pdf?"/billing/statement.pdf":"/billing/export.csv",loadedQuery,pdf?"rechnungsanlage.pdf":"abrechnung.csv");}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const ids=data?[...new Set(data.rows.filter(r=>!r.incomplete&&!r.exported&&!r.invoice).map(r=>r.id))]:[];
return <section className="panel business-billing"><div className="section-toolbar"><div><h2>Abrechnung</h2><p>Kundenleistungen, Kostenstellen und Kuriervergütungen nach Erledigungsdatum.</p></div></div><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}
<form onSubmit={load}><fieldset className="business-fieldset" disabled={busy}><div className="form-grid cols-3"><Field label="Bereich"><select value={query.mode} onChange={e=>{setQuery(q=>({...q,mode:e.target.value}));setSelected([]);}}><option value="CUSTOMER">Kundenabrechnung</option><option value="COURIER">Kurierabrechnung</option></select></Field><Field label="Erledigt von"><input type="date" required value={query.from} onChange={e=>setQuery(q=>({...q,from:e.target.value}))}/></Field><Field label="Erledigt bis"><input type="date" required value={query.to} onChange={e=>setQuery(q=>({...q,to:e.target.value}))}/></Field><Field label="Kunde"><select value={query.customer??""} onChange={e=>setQuery(q=>({...q,customer:e.target.value?Number(e.target.value):null,center:null}))}><option value="">Alle Kunden</option>{options?.customers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Kostenstelle"><select disabled={!query.customer||query.mode!=="CUSTOMER"} value={query.center??""} onChange={e=>setQuery(q=>({...q,center:e.target.value?Number(e.target.value):null}))}><option value="">Alle Kostenstellen</option>{centers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Kurier"><select value={query.courier??""} onChange={e=>setQuery(q=>({...q,courier:e.target.value?Number(e.target.value):null}))}><option value="">Alle Kuriere</option>{options?.couriers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Rechnungsnummer"><input value={query.invoice} maxLength={30} onChange={e=>setQuery(q=>({...q,invoice:e.target.value}))}/></Field><label className="checkbox-label"><input type="checkbox" checked={query.descendants} onChange={e=>setQuery(q=>({...q,descendants:e.target.checked}))}/>Untergeordnete Kostenstellen einbeziehen</label><label className="checkbox-label"><input type="checkbox" checked={query.unassigned} onChange={e=>setQuery(q=>({...q,unassigned:e.target.checked}))}/>Ohne Rechnungszuordnung</label></div><button className="button primary">{busy?"Lädt …":"Anzeigen"}</button></fieldset></form>
{uncertain&&<button className="button primary" disabled={busy} onClick={()=>void assign({preventDefault(){}} as FormEvent)}>Zuordnung erneut prüfen</button>}{filtersChanged&&<p className="alert info">Filter geändert. „Anzeigen“ lädt die neue Auswahl.</p>}{data&&<><div className="business-summary"><span>Positionen <strong>{data.rows.length}</strong></span><span>Netto <strong>{euro(data.net)}</strong></span><span>MwSt <strong>{euro(data.tax)}</strong></span><span>Brutto <strong>{euro(data.gross)}</strong></span></div><div className="admin-row-actions"><button className="button" disabled={busy||uncertain||filtersChanged} onClick={()=>void download(true)}>PDF-Anlage herunterladen</button><button className="button" disabled={busy||uncertain||filtersChanged} onClick={()=>void download(false)}>CSV herunterladen</button><label className="checkbox-label"><input type="checkbox" checked={details} onChange={e=>setDetails(e.target.checked)}/>Rechnungstexte und Positionen</label></div>
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Auswahl</th><th>Auftrag / erledigt</th><th>Kunde / Kostenstelle</th><th>Fahrer</th><th>Rechnung</th><th>Netto</th><th>MwSt</th><th>Brutto</th></tr></thead><tbody>{data.rows.map((r,index)=><tr key={`${r.id}-${r.center}-${index}`}><td><input aria-label={`Auftrag ${r.id} auswählen`} type="checkbox" checked={selected.includes(r.id)} disabled={busy||uncertain||filtersChanged||!ids.includes(r.id)||query.mode!=="CUSTOMER"||!options?.canAssign} onChange={e=>{pending.current=null;setSelected(v=>e.target.checked?[...new Set([...v,r.id])]:v.filter(id=>id!==r.id));}}/></td><td><strong>#{r.id}</strong><small>{r.finished?.replace("T"," ")}</small>{r.incomplete&&<small>Nachbearbeitung erforderlich</small>}{r.storno>0&&<small>{r.storno>2?"Gegenbuchung":"Storno"}</small>}</td><td>{r.customer}<small>{r.centerName}</small>{details&&<><p className="form-hint">{r.paymentTerm?`Zahlungsbedingung: ${r.paymentTerm}`:"Keine Zahlungsbedingung am Auftrag gespeichert"}</p><p className="business-preline">{r.description.replace(/<[^>]+>/g," ")}</p>{r.components?.map((p,i)=><small key={i}>{p.name} · {euro(p.net)}</small>)}</>}</td><td>{r.courier||"–"}</td><td>{r.invoice||"Noch nicht zugeordnet"}{r.exported&&<small>Exportiert</small>}</td><td>{euro(r.net)}</td><td>{euro(r.tax)}</td><td>{euro(r.gross)}</td></tr>)}</tbody></table>{!data.rows.length&&<p>Keine abgeschlossenen Aufträge für diese Auswahl.</p>}</div>
{options?.canAssign&&query.mode==="CUSTOMER"&&<form className="business-section" onSubmit={assign}><h3>Rechnungsangaben zuordnen</h3><fieldset className="business-fieldset" disabled={busy||uncertain||filtersChanged}><p>Rechnungsnummer und Datum werden für die markierten Aufträge gespeichert.</p><div className="admin-row-actions"><button type="button" className="button" onClick={()=>{pending.current=null;setSelected(ids);}}>Alle freigegebenen Aufträge</button><button type="button" className="button" onClick={()=>{pending.current=null;setSelected([]);}}>Keine</button><span>{selected.length} ausgewählt</span></div><div className="form-grid"><Field label="Rechnungsnummer *"><input required maxLength={30} value={number} onChange={e=>{pending.current=null;setUncertain(false);setNumber(e.target.value);}}/></Field><Field label="Rechnungsdatum *"><input required type="date" value={date} onChange={e=>{pending.current=null;setDate(e.target.value);}}/></Field></div><button className="button primary" disabled={!selected.length||!query.customer}>Zuordnung speichern</button>{!query.customer&&<p className="form-hint">Für die Zuordnung einen Kunden auswählen und die Übersicht laden.</p>}</fieldset></form>}
{!!data.pending.length&&<section className="business-section"><h3>Offene Vorgänge</h3>{data.pending.map(p=><div key={p.token} className="admin-row-actions"><button disabled={busy} className="button" onClick={()=>void resume(p.token)}>Zuordnung fortsetzen · {p.token.slice(0,8)}</button><button disabled={busy} className="button" onClick={()=>void resume(p.token,true)}>Vorgang beenden, gespeicherte Zuordnungen behalten</button></div>)}</section>}</>}
</section>;
}
+29
View File
@@ -0,0 +1,29 @@
import {CustomerServices} from "./CustomerServices";
import {MasterAddressLookup} from "./MasterAddressLookup";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { request, put, ApiError } from "./api";
export type Definition={key:string;label:string;group:string;type:string;max:number;required:boolean;options:Record<string,string>};
export type Values=Record<string,string|number|null>;
export type Choice={id:number;label:string};
export type EditorData={canManageServices?:boolean;id:number;parent:number;revision:string;fields:Definition[];values:Values;options:Record<string,Choice[]>;children?:{id:number;label:string;parent?:number;blocked?:boolean}[]};
export const uncertainWrite=(e:unknown)=>!(e instanceof ApiError)||e.status===0||e.status>=500;
export function ErrorMessage({message}:{message:string}){return message?<div className="alert error" role="alert">{message}</div>:null;}
export function BusinessFields({fields,values,options={},onChange,addressKind,disabled=false,onAddress}:{addressKind?:string;disabled?:boolean;onAddress?:(patch:Record<string,string>)=>void;fields:Definition[];values:Values;options?:Record<string,Choice[]>;onChange:(key:string,value:string|number)=>void}){
return <>{[...new Set(fields.map(f=>f.group))].map(group=><section className="business-section" key={group}><h3>{group}</h3>{addressKind&&onAddress&&fields.filter(f=>f.group===group&&f.key.endsWith("ad_street")).map(f=>{const prefix=f.key.slice(0,-"ad_street".length);return <MasterAddressLookup key={f.key} kind={addressKind} prefix={prefix} addressVersion={JSON.stringify(fields.filter(x=>x.group===group).map(x=>values[x.key]))} country={String(values[prefix+"ad_country"]??"DE")} disabled={disabled} onSelect={onAddress}/>;})}<div className="form-grid cols-3">{fields.filter(f=>f.group===group).map(f=>{
const value=values[f.key]??"",choices=f.type==="select"?Object.entries(f.options).map(([id,label])=>({id:Number(id),label})):options[f.type];
return f.type==="check"?<label key={f.key} className="checkbox-label"><input type="checkbox" checked={Number(value)===1} onChange={e=>onChange(f.key,e.target.checked?1:0)}/>{f.label}</label>:<Field key={f.key} label={f.label+(f.required?" *":"")} wide={f.type==="textarea"}>{f.type==="textarea"?<textarea rows={3} value={value} maxLength={f.max} onChange={e=>onChange(f.key,e.target.value)}/>:choices?<select value={value} required={f.required} onChange={e=>onChange(f.key,e.target.value)}>{!choices.some(c=>String(c.id)===String(value))&&<option value={value}>{Number(value)===0?"Nicht festgelegt":`Bestehender Wert ${value}`}</option>}{choices.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select>:<input value={f.type==="datetime-local"?String(value).replace(" ","T").slice(0,16):value} type={f.type==="date"||f.type==="datetime-local"?f.type:["integer","percent","money"].includes(f.type)?"number":"text"} required={f.required} min={["integer","percent","money"].includes(f.type)?0:undefined} max={["integer","percent","money"].includes(f.type)?f.max:undefined} step={f.type==="integer"?1:0.01} maxLength={f.type==="text"||f.type==="emails"?f.max:undefined} onChange={e=>onChange(f.key,e.target.value)}/>}</Field>;
})}</div></section>)}</>;
}
export function MasterDataEditor({kind,id,parent=0,onClose,onSaved}:{kind:string;id:number;parent?:number;onClose:()=>void;onSaved:()=>void}){
const [servicesOpen,setServicesOpen]=useState(false);
const [data,setData]=useState<EditorData|null>(null),[values,setValues]=useState<Values>({}),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[child,setChild]=useState<{kind:string;id:number;parent:number}|null>(null);
const [discard,dialog]=useDiscardConfirmation();const pending=useRef<{requestId:string;revision:string;values:Values}|null>(null);
const dirty=!!data&&JSON.stringify(values)!==JSON.stringify(data.values);const name=kind==="customers"?"Kunde":kind==="couriers"?"Transporteur":kind==="centers"?"Kostenstelle":"Fahrzeug";
async function load(target=id){try{const d=await request<EditorData>(`/master-data/${kind}/${target}?parent=${parent}`);setData(d);setValues(d.values);setError("");}catch(e){setError((e as Error).message);}}
useEffect(()=>{void load();},[kind,id,parent]);
async function close(){if(!busy&&!uncertain&&(!dirty||await discard("Ungespeicherte Stammdatenänderungen verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();if(!data)return;setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),revision:data.revision,values};try{const d=await put<EditorData>(`/master-data/${kind}/${data.id}?parent=${parent}`,pending.current);setData(d);setValues(d.values);setNotice("Stammdaten gespeichert.");pending.current=null;setUncertain(false);onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?" Speicherausgang unklar. Bitte denselben Vorgang mit Speichern erneut prüfen.":""));}finally{setBusy(false);}}
return <><Modal title={`${name} ${data?.id?`#${data.id} bearbeiten`:"anlegen"}`} onClose={()=>void close()} wide><form onSubmit={save}><div className="modal-body"><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}{!data&&!error&&<p>Stammdaten werden geladen …</p>}{data&&<>{data.id>0&&data.canManageServices&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain} onClick={()=>setServicesOpen(true)}>Servicezuordnungen</button><p className="form-hint">Preistypen und Leistungstypen des Kunden auswählen.</p></section>}<fieldset className="business-fieldset" disabled={busy||uncertain}><BusinessFields fields={data.fields} values={values} options={data.options} addressKind={kind} disabled={busy||uncertain} onAddress={patch=>{pending.current=null;setValues(v=>({...v,...patch}));}} onChange={(key,value)=>{pending.current=null;setValues(v=>({...v,[key]:value}));}}/></fieldset>{data.id>0&&data.children&&<section className="business-section"><h3>{kind==="customers"?"Kostenstellen":"Fahrzeuge"}</h3><p className="form-hint">Änderungen am Stammblatt zuerst speichern, bevor Sie einen zugeordneten Datensatz öffnen.</p><button type="button" className="button" disabled={dirty||busy||uncertain} onClick={()=>setChild({kind:kind==="customers"?"centers":"vehicles",id:0,parent:data.id})}>Neu anlegen</button><div className="business-child-list">{data.children.map(c=><button className="button" type="button" key={c.id} disabled={dirty||busy||uncertain} onClick={()=>setChild({kind:kind==="customers"?"centers":"vehicles",id:c.id,parent:data.id})}>{c.label}{c.blocked?" · gesperrt":""}</button>)}</div></section>}</>}</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button primary" disabled={busy||!data||(!dirty&&data.id>0)}>{busy?"Speichert …":"Speichern"}</button></div></form></Modal>{dialog}{servicesOpen&&data&&<CustomerServices id={data.id} onClose={()=>setServicesOpen(false)}/>} {child&&<MasterDataEditor {...child} onSaved={onSaved} onClose={()=>{setChild(null);void load(data?.id);}}/>}</>;
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { request, post, put } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { Lookup } from "./EntryLookup";
import type { AppointmentOption } from "./AppointmentForm";
import type { CrmOptions } from "./SalesWorkspace";
type Definition = { id: number; keyId: number; label: string; type: string; history: number };
type Schema = { fields: Definition[]; revision: string };
type Contact = { id: number; headquartersId: number; fields: { fieldId: number; label: string; type: string; value: string; displayValue?: string }[] };
type Detail = { id: number; headquartersId: number; values: Record<number, string>; customerLabels?: Record<string, string>; revision: string; schema: Schema };
const label = (s: string) => s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&");
export function download(content: string, name: string, type: string) { const url = URL.createObjectURL(new Blob([content], { type })); const link = document.createElement("a"); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
function vcard(c: Contact) {
const val = (name: string) => c.fields.find(f => label(f.label) === name)?.value ?? "";
const escape = (s: string) => s.replace(/\\/g, "\\\\").replace(/\r?\n/g, "\\n").replace(/;/g, "\\;").replace(/,/g, "\\,");
const lines = ["BEGIN:VCARD", "VERSION:3.0", `N:${escape(val("Name"))};${escape(val("Vorname"))};;;`, `FN:${escape(`${val("Vorname")} ${val("Name")}`.trim())}`, `ADR;TYPE=WORK:;;${escape(`${val("Strasse")} ${val("Hausnr.")}`.trim())};${escape(val("Ort"))};;${escape(val("PLZ"))};${escape(val("Land"))}`, `TITLE:${escape(val("Position"))}`, `NOTE:${escape(val("Bemerkung"))}`];
for (const f of c.fields) { if (!f.value) continue; if (f.type.startsWith("mail_")) lines.push(`EMAIL;TYPE=${f.type.endsWith("private") ? "HOME" : "WORK"}:${escape(f.value)}`); if (/^(phone|mobile|fax)_/.test(f.type)) lines.push(`TEL;TYPE=${f.type.startsWith("mobile") ? "CELL" : f.type.startsWith("fax") ? "FAX" : "VOICE"},${f.type.endsWith("private") ? "HOME" : "WORK"}:${escape(f.value)}`); }
lines.push("END:VCARD"); download(lines.join("\r\n") + "\r\n", `Kontakt-${c.id}.vcf`, "text/vcard;charset=utf-8");
}
export function CrmContacts({ options, onDirtyChange }: { options: CrmOptions; onDirtyChange: (d: boolean) => void }) {
const [hqs, setHqs] = useState([options.currentHeadquarters]), [search, setSearch] = useState(""), [prefix, setPrefix] = useState(false), [letter, setLetter] = useState(""), [cards, setCards] = useState(true);
const [customer, setCustomer] = useState<AppointmentOption | null>(null), [items, setItems] = useState<Contact[]>([]), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
const [edit, setEdit] = useState<Detail | null>(null), [createHq, setCreateHq] = useState(options.currentHeadquarters);
async function load(initial?: boolean) { setBusy(true); setError(""); try { setItems(await post<Contact[]>("/crm/contacts/query", { headquarters: initial ? [options.currentHeadquarters] : hqs, search: initial ? "" : search, prefix, letter: initial ? "" : letter, customerId: customer?.id ?? null })); } catch (e) { setError((e as Error).message); setItems([]); } finally { setBusy(false); } }
useEffect(() => { void load(true); }, []);
async function open(c?: Contact) { setBusy(true); setError(""); try { setEdit(c ? await request<Detail>(`/crm/contacts/${c.id}?headquarters=${c.headquartersId}`) : { id: 0, headquartersId: createHq, values: {}, revision: "", schema: await request<Schema>(`/crm/contacts/schema?headquarters=${createHq}`) }); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
return <section className="panel crm-panel"><div className="admin-title"><div><h2>Kontakte</h2><p>Ansprechpartner, Kundenzuordnung und Kontaktverlauf.</p></div><div className="admin-row-actions"><Field label="Neuen Kontakt anlegen in"><select value={createHq} onChange={e => setCreateHq(+e.target.value)}>{options.headquarters.map(h => <option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><button className="button primary" disabled={busy} onClick={() => void open()}>Neuer Kontakt</button></div></div>
{error && <p role="alert" className="alert error">{error}</p>}{notice && <p role="status" className="admin-success">{notice}</p>}
<form onSubmit={e => { e.preventDefault(); void load(); }}><fieldset disabled={busy}><div className="admin-toolbar"><Field label="Kontakt suchen"><input type="search" value={search} onChange={e => setSearch(e.target.value)} /></Field><Field label="Suchmodus"><select value={prefix ? "prefix" : "contains"} onChange={e => setPrefix(e.target.value === "prefix")}><option value="contains">Teilwort</option><option value="prefix">Präfix</option></select></Field><Field label="Anfangsbuchstabe des Nachnamens"><select value={letter} onChange={e => setLetter(e.target.value)}><option value="">Alle</option>{Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ").map(c => <option key={c}>{c}</option>)}</select></Field><button className="button primary" disabled={!hqs.length}>Suchen</button><button type="button" className="button" onClick={() => { setSearch(""); setLetter(""); setCustomer(null); }}>Filter zurücksetzen</button></div>
<Lookup<AppointmentOption> label="Kontakte für Kunde" endpoint="/appointments/customers" value={customer} describe={c => c.label} onSelect={setCustomer} /><div className="admin-toolbar">{options.headquarters.map(h => <label key={h.id} className="admin-check"><input type="checkbox" checked={hqs.includes(h.id)} onChange={e => setHqs(e.target.checked ? [...hqs, h.id] : hqs.filter(id => id !== h.id))} />{h.label}</label>)}</div></fieldset></form>
<div className="admin-toolbar"><strong>{items.length} Kontakte</strong><button className="button" aria-pressed={cards} onClick={() => setCards(true)}>Karten</button><button className="button" aria-pressed={!cards} onClick={() => setCards(false)}>Liste</button></div>
<div className={cards ? "crm-contact-grid" : "crm-contact-list"}>{items.map(c => <article className="crm-contact" key={`${c.headquartersId}-${c.id}`}><h3>{c.fields.filter(f => ["Name", "Vorname"].includes(label(f.label))).map(f => f.value).join(", ") || `Kontakt ${c.id}`} <small>{options.headquarters.find(h => h.id === c.headquartersId)?.label}</small></h3><dl>{c.fields.filter(f => f.value && !["Name", "Vorname"].includes(label(f.label))).slice(0, cards ? 8 : 5).map(f => <div key={f.fieldId}><dt>{label(f.label)}</dt><dd>{f.type.startsWith("mail_") && /^[^\s@]+@[^\s@]+$/.test(f.value) ? <a href={`mailto:${encodeURIComponent(f.value)}`}>{f.value}</a> : /^(phone|mobile)_/.test(f.type) ? <a href={`tel:${f.value.replace(/[^\d+]/g, "")}`}>{f.value}</a> : f.displayValue ?? f.value}</dd></div>)}</dl><div className="admin-row-actions"><button className="button" disabled={busy} onClick={() => void open(c)}>Kontakt öffnen</button><button className="button" onClick={() => vcard(c)}>vCard</button></div></article>)}</div>{!busy && !items.length && <p>Keine Kontakte für diese Auswahl.</p>}
{edit && <ContactEditor contact={edit} onDirtyChange={onDirtyChange} onClose={() => setEdit(null)} onSaved={async message => { setEdit(null); onDirtyChange(false); setNotice(message); await load(); }} />}</section>;
}
function ContactEditor({ contact: c, onDirtyChange, onClose, onSaved }: { contact: Detail; onDirtyChange: (d: boolean) => void; onClose: () => void; onSaved: (message: string) => Promise<void> }) {
const [values, setValues] = useState(c.values), [error, setError] = useState(""), [busy, setBusy] = useState(false), [deleting, setDeleting] = useState(false), [history, setHistory] = useState<{ fieldId: number; value: string; time: string }[] | null>(null);
const [printPreview, setPrintPreview] = useState(false);
const [ask, dialog] = useDiscardConfirmation(); const changed = JSON.stringify(values) !== JSON.stringify(c.values);
useEffect(() => { onDirtyChange(changed || busy); return () => onDirtyChange(false); }, [changed, busy, onDirtyChange]);
const close = async () => { if (!busy && (!changed || await ask("Ungespeicherten Kontakt verwerfen?"))) onClose(); };
async function save() { setBusy(true); setError(""); try { const body = { headquarters: c.headquartersId, revision: c.revision, schemaRevision: c.schema.revision, values }; const result = c.id ? await put<{ id: number }>(`/crm/contacts/${c.id}`, body) : await post<{ id: number }>("/crm/contacts", body); await onSaved(`Kontakt ${result.id} gespeichert.`); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
async function remove() { setBusy(true); setError(""); try { await request(`/crm/contacts/${c.id}`, { method: "DELETE", body: JSON.stringify({ headquarters: c.headquartersId, revision: c.revision }) }); await onSaved(`Kontakt ${c.id} gelöscht.`); } catch (e) { setError((e as Error).message); setDeleting(false); } finally { setBusy(false); } }
async function loadHistory() { setBusy(true); setError(""); try { setHistory(await request(`/crm/contacts/${c.id}/history?headquarters=${c.headquartersId}`)); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
return <><Modal title={c.id ? `Kontakt ${c.id}` : "Neuer Kontakt"} wide onClose={() => void close()}><form className="admin-dialog-body" onSubmit={e => { e.preventDefault(); void save(); }}>{error && <p role="alert" className="alert error">{error}</p>}<fieldset disabled={busy}><div className="appointment-form-grid">{c.schema.fields.map(f => f.type === "co2cs" ? <Lookup<AppointmentOption> key={f.id} label={label(f.label)} endpoint="/appointments/customers" value={values[f.id] ? { id: +values[f.id], label: c.customerLabels?.[values[f.id]] ?? `Kunde ${values[f.id]}` } : null} describe={o => o.label} onSelect={o => setValues({ ...values, [f.id]: o ? String(o.id) : "" })} /> : <Field key={f.id} label={label(f.label)}>{f.type === "text" ? <textarea rows={3} value={values[f.id] ?? ""} maxLength={20000} onChange={e => setValues({ ...values, [f.id]: e.target.value })} /> : <input type={f.type.startsWith("mail_") ? "email" : "text"} placeholder={["date", "birthdate"].includes(f.type) ? "TT.MM.JJJJ" : undefined} value={values[f.id] ?? ""} maxLength={2000} onChange={e => setValues({ ...values, [f.id]: e.target.value })} />}</Field>)}</div></fieldset>
{history && <section><h3>Kontaktverlauf</h3><p className="admin-hint">Die letzten 1000 Änderungen an Feldern mit aktivierter Historie.</p>{!history.length && <p>Keine protokollierten Feldänderungen.</p>}<dl>{history.map((h, i) => <div key={i}><dt>{new Date(h.time).toLocaleString("de-DE")} · {label(c.schema.fields.find(f => f.id === h.fieldId)?.label ?? String(h.fieldId))}</dt><dd>{h.value || "(geleert)"}</dd></div>)}</dl></section>}
<div className="admin-row-actions"><button className="button" type="button" disabled={busy} onClick={() => setPrintPreview(true)}>Druckansicht / PDF</button>{c.id > 0 && <><button className="button" type="button" disabled={busy} onClick={() => void loadHistory()}>Verlauf</button><button className="button danger" type="button" disabled={busy} onClick={() => setDeleting(true)}>Kontakt löschen</button></>}<button className="button" type="button" disabled={busy} onClick={() => void close()}>Schließen</button><button className="button primary" disabled={busy || !changed}>Kontakt speichern</button></div></form></Modal>{dialog}{printPreview && <Modal title="Kontakt drucken" onClose={() => setPrintPreview(false)} wide><article className="invoice-print crm-contact-print"><h2>Kontakt {c.id || "· neu"}{changed ? " · Entwurf" : ""}</h2><dl>{c.schema.fields.filter(f => values[f.id]).map(f => <div key={f.id}><dt>{label(f.label)}</dt><dd>{f.type === "co2cs" ? c.customerLabels?.[values[f.id]] ?? `Kunde ${values[f.id]}` : values[f.id]}</dd></div>)}</dl></article><div className="modal-footer"><button className="button primary" onClick={() => window.print()}>Drucken / als PDF speichern</button></div></Modal>}{deleting && <Modal title="Kontakt löschen?" onClose={() => { if (!busy) setDeleting(false); }}><div className="admin-dialog-body"><p>Kontakt {c.id} aus dieser Niederlassung dauerhaft löschen?</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={() => setDeleting(false)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={() => void remove()}>Endgültig löschen</button></div></div></Modal>}</>;
}
+24
View File
@@ -0,0 +1,24 @@
import {useEffect,useRef,useState} from 'react';
import {ArrowDown,ArrowUp,Save} from 'lucide-react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {request,put,post} from './api';
import {ErrorMessage,uncertainWrite} from './BusinessForms';
type Option={id:number;label:string;mode:number};
type Data={revision:string;services:number[];types:number[];serviceOptions:Option[];typeOptions:Option[];operations:{token:string;canResume:boolean}[]};
type Result={state:string;message?:string;token:string};
export function CustomerServices({id,onClose}:{id:number;onClose:()=>void}) {
const base=`/master/customers/${id}/services`;
const [data,setData]=useState<Data|null>(null),[services,setServices]=useState<number[]>([]),[types,setTypes]=useState<number[]>([]),[mode,setMode]=useState(1),[query,setQuery]=useState('');
const [busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [discard,dialog]=useDiscardConfirmation();const pending=useRef<object|null>(null);
const dirty=!!data&&(JSON.stringify(services)!==JSON.stringify(data.services)||JSON.stringify(types)!==JSON.stringify(data.types));
async function load(){const d=await request<Data>(base);setData(d);setServices(d.services);setTypes(d.types);}
useEffect(()=>{setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));},[id]);
async function close(){if(!busy&&!uncertain&&(!dirty||await discard('Ungespeicherte Servicezuordnungen verwerfen?')))onClose();}
async function save(token?:string){if(!data)return;setBusy(true);setError('');setNotice('');try{pending.current??={requestId:crypto.randomUUID(),revision:data.revision,services,types};const result=token?await post<Result>(`${base}/operations/${token}`):await put<Result>(base,pending.current);pending.current=null;setUncertain(false);if(result.state==='DONE')setNotice('Servicezuordnungen gespeichert.');else setError(result.message||'Der gesicherte Vorgang ist noch nicht abgeschlossen.');await load();}catch(e){setUncertain(uncertainWrite(e));if(!uncertainWrite(e))pending.current=null;setError((e as Error).message);}finally{setBusy(false);}}
function change(kind:'services'|'types',ids:number[]){pending.current=null;if(kind==='services')setServices(ids);else setTypes(ids);}
const blocked=busy||uncertain||!!data?.operations.length;
return <><Modal title={`Servicezuordnungen · Kunde ${id}`} onClose={()=>void close()} wide><div className="admin-dialog-body"><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}<p>Preistypen und Leistungstypen für die kundenspezifische Kalkulation auswählen und ordnen.</p>
{data?.operations.map(o=><div className="report-warning" key={o.token}><p>Ein gespeicherter Vorgang ist noch offen.{!o.canResume&&' Er muss vom ursprünglichen Bearbeiter fortgesetzt werden.'}</p><button className="button" disabled={busy||!o.canResume} onClick={()=>void save(o.token)}>Vorgang fortsetzen</button></div>)}
{data&&<><div className="admin-toolbar"><Field label="Servicemodus"><select value={mode} disabled={blocked} onChange={e=>setMode(Number(e.target.value))}><option value={1}>Preis-/Leistungstypen</option><option value={0}>Services</option></select></Field><Field label="Zuordnungen suchen"><input type="search" value={query} onChange={e=>setQuery(e.target.value)} disabled={blocked}/></Field></div><fieldset disabled={blocked}><div className="form-grid cols-2">{(['services','types'] as const).map(kind=>{const selected=kind==='services'?services:types,options=kind==='services'?data.serviceOptions:data.typeOptions,label=kind==='services'?'Preistypen':'Leistungstypen';return <section key={kind}><h3>{label}</h3><p className="form-hint">{selected.length} zugeordnet · Reihenfolge bleibt gespeichert</p><ol className="customer-services-selected">{selected.map((v,index)=><li key={v}><span>{options.find(o=>o.id===v)?.label||`Bestandszuordnung #${v}`}</span><button className="icon-button" aria-label={`${label}: ${v} nach oben`} disabled={!index} onClick={()=>{const ids=[...selected];[ids[index-1],ids[index]]=[ids[index],ids[index-1]];change(kind,ids);}}><ArrowUp size={15}/></button><button className="icon-button" aria-label={`${label}: ${v} nach unten`} disabled={index===selected.length-1} onClick={()=>{const ids=[...selected];[ids[index+1],ids[index]]=[ids[index],ids[index+1]];change(kind,ids);}}><ArrowDown size={15}/></button><button className="button" onClick={()=>change(kind,selected.filter(i=>i!==v))}>Entfernen</button></li>)}</ol><div className="customer-services-available">{options.filter(o=>o.mode===mode&&o.label.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de'))).map(o=><label className="checkbox-label" key={o.id}><input type="checkbox" checked={selected.includes(o.id)} onChange={e=>change(kind,e.target.checked?[...selected,o.id]:selected.filter(i=>i!==o.id))}/>{o.label}</label>)}</div></section>;})}</div></fieldset><div className="admin-row-actions"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button primary" disabled={busy||!!data.operations.length||(!dirty&&!uncertain)} onClick={()=>void save()}><Save size={16}/>{uncertain?'Speichervorgang erneut prüfen':'Zuordnungen speichern'}</button></div></>}{busy&&!data&&<p>Servicezuordnungen werden geladen …</p>}</div></Modal>{dialog}</>;
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useEntryApi } from "./EntryScope";
import { Field } from "./FormElements";
export function Lookup<T extends { id: number }>({ label, endpoint, value, describe, onSelect, disabled = false, shortcut }: {
label: string; endpoint: string; value: T | null; describe: (value: T) => string; onSelect: (value: T | null) => void; disabled?: boolean; shortcut?: string;
}) {
const { request } = useEntryApi();
const [query, setQuery] = useState(""), [items, setItems] = useState<T[]>([]), [error, setError] = useState(""), [loading, setLoading] = useState(false);
useEffect(() => {
let current = true;
if (value || disabled) return;
const timer = setTimeout(() => {
setLoading(true); setError("");
request<T[]>(`${endpoint}${endpoint.includes("?") ? "&" : "?"}q=${encodeURIComponent(query)}`).then(result => { if (current) setItems(result); })
.catch((e: Error) => { if (current) { setError(e.message); setItems([]); } })
.finally(() => { if (current) setLoading(false); });
}, 250);
return () => { current = false; clearTimeout(timer); };
}, [query, endpoint, value, disabled, request]);
return <div className="entry-lookup">
{value ? <div className="entry-selected"><span><small>{label}</small><strong>{describe(value)}</strong></span>
<button className="icon-button" type="button" disabled={disabled} data-entry-key={shortcut} aria-label={`${label} ändern`} onClick={() => { onSelect(null); setQuery(""); }}><X size={16} /></button></div>
: <><Field label={label}><input data-entry-key={shortcut} type="search" value={query} placeholder="Nummer oder Name suchen …" disabled={disabled} onChange={e => setQuery(e.target.value)} /></Field>
{loading ? <p role="status" className="form-hint">Suche läuft …</p> : error ? <p role="alert" className="alert error">{error}</p> : <select aria-label={`${label} auswählen`} value="" disabled={disabled} onChange={e => onSelect(items.find(item => item.id === Number(e.target.value)) || null)}>
<option value="">{items.length ? "Treffer auswählen …" : "Keine Treffer"}</option>{items.map(item => <option key={item.id} value={item.id}>{describe(item)}</option>)}
</select>}
<p className="form-hint">Bis zu 50 Treffer. Suche bei Bedarf eingrenzen.</p></>}
</div>;
}
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, useMemo } from "react";
import { request as apiRequest } from "./api";
export const EntryScope = createContext<number | null>(null);
export function useEntryApi() {
const headquarters = useContext(EntryScope);
return useMemo(() => {
function request<T>(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers);
if (headquarters !== null) headers.set("X-Order-Headquarters", String(headquarters));
return apiRequest<T>(path, { ...options, headers });
}
function post<T>(path: string, body: unknown) { return request<T>(path, { method: "POST", body: JSON.stringify(body) }); }
return { request, post };
}, [headquarters]);
}
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from 'react';
import { Field } from './FormElements';
import { request } from './api';
type Def={id:number;label:string;type:string};type Hq={id:number;label:string};
const kinds:Record<string,string>={'button:cs:cs_eid':'customer','button:cr:cr_eid':'courier','select:crvh:crvh_sid':'vehicle','select:vht':'vehicleType','select:grp':'group','select:br':'branch'};
export function EvaluationField({field,value,headquarters,headquartersOptions,onChange}:{field:Def;value:string;headquarters:number;headquartersOptions:Hq[];onChange:(v:string)=>void}){
const [query,setQuery]=useState(''),[items,setItems]=useState<{id:number;value:string|number;label:string}[]>([]),[error,setError]=useState('');const kind=kinds[field.type];
useEffect(()=>{if(!kind)return;let live=true;const timer=setTimeout(()=>request<typeof items>('/evaluations/lookup?'+new URLSearchParams({headquarters:String(headquarters),kind,q:query})).then(v=>{if(live){setItems(v);setError('');}}).catch(e=>{if(live)setError(e.message);}),250);return()=>{live=false;clearTimeout(timer);};},[query,kind,headquarters]);
if(['date_begin','date_end','date'].includes(field.type)){const match=value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/),iso=match?`${match[3]}-${match[2]}-${match[1]}`:'';return <Field label={field.label}><input type="date" value={iso} onInput={e=>{const v=e.currentTarget.value;onChange(v?v.split('-').reverse().join('.'):'');}}/></Field>;}
if(field.type==='checkboxes_hq'){const selected=value.split(',').filter(Boolean);return <fieldset><legend>{field.label}</legend><div className="admin-toolbar">{headquartersOptions.map(h=><label className="admin-check" key={h.id}><input type="checkbox" checked={selected.includes(String(h.id))} onChange={e=>onChange((e.target.checked?[...selected,String(h.id)]:selected.filter(v=>v!==String(h.id))).join(','))}/>{h.label}</label>)}<button type="button" className="button" onClick={()=>onChange(headquartersOptions.map(h=>h.id).join(','))}>Alle</button><button type="button" className="button" onClick={()=>onChange('')}>Keine</button></div></fieldset>;}
if(!kind)return <Field label={field.label}><input readOnly value={value}/></Field>;
return <div className="entry-lookup"><Field label={field.label}><input value={value} onChange={e=>onChange(e.target.value)} placeholder="Keine Auswahl"/></Field><Field label={`${field.label} suchen`}><input type="search" value={query} onChange={e=>setQuery(e.target.value)} placeholder="Nummer oder Name"/></Field>{error&&<p role="alert" className="alert error">{error}</p>}<select aria-label={`${field.label} auswählen`} value="" onChange={e=>{const v=e.target.value;if(v)onChange(v);}}><option value="">Treffer auswählen …</option>{items.map(v=><option key={v.id} value={v.value}>{v.label}</option>)}</select><p className="form-hint">Bis zu 100 Treffer. Leeres Feld hebt die Auswahl auf.</p></div>;
}
+28
View File
@@ -0,0 +1,28 @@
import {useEffect,useState,type FormEvent} from 'react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {request,post,put,ApiError} from './api';
type Filter={id:number;type:number;code:string;name:string;status:number;sort:number;description:string;revision:string};
type Result={token:string;state:'DONE'|'PENDING';message:string};
type Catalog={manage:boolean;items:Filter[];operations:{id:number;filterId:number;token:string;state:string}[]};
export function Filters({onDirty}:{onDirty:(v:boolean)=>void}){
const [catalog,setCatalog]=useState<Catalog|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[reload,setReload]=useState(0),[sort,setSort]=useState('code'),[search,setSearch]=useState(''),[details,setDetails]=useState(false),[editor,setEditor]=useState<Filter|'new'|null>(null),[deleting,setDeleting]=useState<Filter|null>(null),[busy,setBusy]=useState(false);
useEffect(()=>{let live=true;request<Catalog>('/administration/filters').then(d=>{if(live){setCatalog(d);setError('');}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[reload]);
async function resume(token:string){setBusy(true);setError('');try{done(await post<Result>(`/administration/filters/operations/${token}/resume`));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function done(r:Result){setNotice(r.message);setEditor(null);setDeleting(null);setReload(v=>v+1);}
const items=(catalog?.items||[]).filter(f=>`${f.code} ${f.name} ${f.description}`.toLowerCase().includes(search.toLowerCase())).sort((a,b)=>sort==='sort'?a.sort-b.sort||a.code.localeCompare(b.code,'de'):sort==='name'?a.name.localeCompare(b.name,'de'):a.code.localeCompare(b.code,'de'));
return <><div className="admin-title"><div><h2>Auftragsfilter</h2><p>Fahrzeug- und Transporteursfilter gelten für alle Niederlassungen dieser Datenbank.</p></div>{catalog?.manage&&<button className="button primary" onClick={()=>setEditor('new')}>Neuer Filter</button>}</div>{error&&<div role="alert" className="alert error">{error}</div>}{notice&&<p role="status" className="admin-success">{notice}</p>}{!!catalog?.operations.length&&<section><h3>Gesicherte, noch offene Vorgänge</h3>{catalog.operations.map(o=><p key={o.id}>Vorgang #{o.id} · {o.filterId?`Filter #${o.filterId}`:'Neuanlage'} <button className="button" disabled={busy} onClick={()=>void resume(o.token)}>Vorgang fortsetzen</button></p>)}</section>}<div className="admin-toolbar"><Field label="Filter suchen"><input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></Field><Field label="Sortierung"><select value={sort} onChange={e=>setSort(e.target.value)}><option value="code">Kürzel</option><option value="name">Bezeichnung</option><option value="sort">Sortiernummer</option></select></Field><label className="admin-check"><input type="checkbox" checked={details} onChange={e=>setDetails(e.target.checked)}/>Langtexte anzeigen</label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Kürzel</th><th>Bezeichnung</th><th>Art / Pflicht</th><th>Sortiernr.</th>{details&&<th>Langtext</th>}<th>Aktionen</th></tr></thead><tbody>{items.map(f=><tr key={f.id}><td><strong>{f.code}{f.status===1?'*':''}</strong></td><td>{f.name}</td><td>{f.type===0?'Fahrzeug':'Transporteur'} · {f.status===1?'Pflicht':f.status===2?'Optional':'Info'}</td><td>{f.sort}</td>{details&&<td className="message-text">{f.description}</td>}<td>{catalog?.manage&&<><button className="button" onClick={()=>setEditor(f)}>Bearbeiten</button><button className="button danger" onClick={()=>setDeleting(f)}>Löschen</button></>}</td></tr>)}</tbody></table></div><p className="form-hint">{items.length} Filter · * Pflichtfilter</p>{editor&&<FilterEditor value={editor} onDirty={onDirty} onClose={()=>setEditor(null)} onSaved={done}/>} {deleting&&<FilterDelete value={deleting} onDirty={onDirty} onClose={()=>setDeleting(null)} onSaved={done}/>}</>;
}
function FilterEditor({value,onDirty,onClose,onSaved}:{value:Filter|'new';onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:(r:Result)=>void}){
const initial=value==='new'?{type:0,code:'',name:'',status:1,sort:0,description:'',revision:''}:value;
const [draft,setDraft]=useState(initial),[requestId]=useState(()=>crypto.randomUUID()),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false),[error,setError]=useState('');const [discard,dialog]=useDiscardConfirmation();const dirty=JSON.stringify(draft)!==JSON.stringify(initial);
useEffect(()=>{onDirty(busy||dirty);return()=>onDirty(false);},[busy,dirty,onDirty]);
async function close(){if(!busy&&(!dirty||await discard(attempted?'Dialog schließen? Einen bereits gesicherten Filtervorgang finden Sie in der Liste der offenen Vorgänge.':'Ungespeicherte Filteränderungen verwerfen?')))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setAttempted(true);setError('');try{const body={...draft,requestId};onSaved(value==='new'?await post<Result>('/administration/filters',body):await put<Result>(`/administration/filters/${value.id}`,body));}catch(e){setError((e as Error).message);if(e instanceof ApiError&&[400,403,409].includes(e.status))setAttempted(false);}finally{setBusy(false);}}
function set<K extends keyof typeof draft>(k:K,v:typeof draft[K]){setDraft(d=>({...d,[k]:v}));}
return <><Modal title={value==='new'?'Filter anlegen':`Filter ${value.code} bearbeiten`} onClose={close}><form onSubmit={save} className="admin-dialog-body">{error&&<div role="alert" className="alert error">{error}</div>}<fieldset disabled={busy||attempted}><Field label="Kürzel"><input required maxLength={4} readOnly={value!=='new'} value={draft.code} onChange={e=>set('code',e.target.value)}/></Field><Field label="Bezeichnung"><input required maxLength={50} readOnly={value!=='new'} value={draft.name} onChange={e=>set('name',e.target.value)}/></Field><Field label="Filterart"><select disabled={value!=='new'} value={draft.type} onChange={e=>set('type',Number(e.target.value))}><option value={0}>Fahrzeugfilter</option><option value={1}>Transporteursfilter</option></select></Field><Field label="Pflicht"><select disabled={value!=='new'} value={draft.status} onChange={e=>set('status',Number(e.target.value))}>{draft.status===0&&<option value={0}>Info</option>}<option value={1}>Pflichtfilter</option><option value={2}>Optionaler Filter</option></select></Field><Field label="Sortiernummer"><input type="number" required min={-999999} max={9999999} step={1} value={draft.sort} onChange={e=>set('sort',Number(e.target.value))}/></Field><Field label="Langtext"><textarea rows={6} maxLength={30000} value={draft.description} onChange={e=>set('description',e.target.value)}/></Field></fieldset><p className="form-hint">Änderungen gelten zentral. Bestehende Kürzel und Klassifizierungen bleiben erhalten.</p><div className="admin-row-actions"><button type="button" className="button" disabled={busy} onClick={close}>Abbrechen</button><button className="button primary" disabled={busy||!dirty}>{attempted?'Denselben Vorgang erneut abrufen':'Speichern'}</button></div></form></Modal>{dialog}</>;
}
function FilterDelete({value,onDirty,onClose,onSaved}:{value:Filter;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:(r:Result)=>void}){
const [requestId]=useState(()=>crypto.randomUUID()),[busy,setBusy]=useState(false),[error,setError]=useState('');useEffect(()=>{onDirty(busy);return()=>onDirty(false);},[busy,onDirty]);
async function remove(){setBusy(true);setError('');try{onSaved(await request<Result>(`/administration/filters/${value.id}`,{method:'DELETE',body:JSON.stringify({requestId,revision:value.revision})}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title="Filter löschen" onClose={()=>{if(!busy)onClose();}}><div className="admin-dialog-body">{error&&<div role="alert" className="alert error">{error}</div>}<p>„{value.code} · {value.name}“ dauerhaft entfernen?</p><p>Sofern kein weiterer Filter dasselbe Kürzel verwendet, werden die Zuordnungen aktiver Kunden, Transporteure und Fahrzeuge entfernt. Tourvorlagen und noch offene Dauerauftragsvorlagen werden wie in PHP über die nächtliche Bereinigung nachgeführt. Das Kürzel bleibt bis zum Abschluss dieser Bereinigung für Neuanlagen gesperrt.</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={onClose}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Filter löschen</button></div></div></Modal>;
}
+122
View File
@@ -0,0 +1,122 @@
import { cloneElement, isValidElement, useEffect, useId, useRef, useState, useCallback, type ReactElement, type ReactNode } from "react";
import { X } from "lucide-react";
export function Field({
label,
children,
wide = false,
}: {
label: string;
children: ReactNode;
wide?: boolean;
}) {
const labelId = useId();
return (
<label className={`field ${wide ? "wide" : ""}`}>
<span id={labelId}>{label}</span>
{isValidElement(children) ? cloneElement(children as ReactElement<{ "aria-labelledby"?: string }>, { "aria-labelledby": labelId }) : children}
</label>
);
}
export function Modal({
title,
subtitle,
children,
onClose,
wide = false,
}: {
title: string;
subtitle?: string;
children: ReactNode;
onClose: () => void;
wide?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const closeRef = useRef(onClose);
useEffect(() => { closeRef.current = onClose; }, [onClose]);
useEffect(() => {
const previous = document.activeElement as HTMLElement;
const target = ref.current;
target?.querySelector<HTMLElement>("input,select,button")?.focus();
const key = (e: KeyboardEvent) => {
const dialogs = document.querySelectorAll('[role="dialog"]');
if (dialogs[dialogs.length - 1] !== target) return;
if (e.key === "Escape") closeRef.current();
if (e.key === "Tab" && target) {
const items = Array.from(
target.querySelectorAll<HTMLElement>(
"button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),a[href]",
),
);
if (!items.length) return;
const first = items[0],
last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", key);
return () => {
document.removeEventListener("keydown", key);
previous?.focus();
};
}, []);
return (
<div
className="modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={ref}
className={`modal ${wide ? "modal-wide" : ""}`}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className="modal-heading">
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
<button
className="icon-button"
onClick={onClose}
aria-label="Schließen"
>
<X size={21} />
</button>
</div>
{children}
</div>
</div>
);
}
/** An application dialog also works inside the embedded browser. */
export function useDiscardConfirmation(): [(message:string)=>Promise<boolean>,ReactNode] {
const [message,setMessage]=useState<string|null>(null);
const pending=useRef<((allow:boolean)=>void)|null>(null);
useEffect(()=>()=>{pending.current?.(false);},[]);
const ask=useCallback((text:string)=>{
pending.current?.(false);
setMessage(text);
return new Promise<boolean>(resolve=>{pending.current=resolve;});
},[]);
function finish(allow:boolean) {
const resolve=pending.current;pending.current=null;setMessage(null);resolve?.(allow);
}
const dialog=message&&<Modal title="Ungespeicherte Änderungen" onClose={()=>finish(false)}>
<div className="admin-dialog-body"><p>{message}</p><div className="admin-row-actions">
<button className="button" onClick={()=>finish(false)}>Weiterbearbeiten</button>
<button className="button danger" onClick={()=>finish(true)}>Verwerfen</button>
</div></div>
</Modal>;
return [ask,dialog];
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState, type FormEvent } from "react";
import { ArrowDown, ArrowUp, FileText, List, LoaderCircle, Plus } from "lucide-react";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { request, post, put } from "./api";
type Group={id:number;label:string};
type Category={id:number;mnemonic:string;description:string;template:number;groups:number[];revision:string};
type MetaField={id:number;name:string;type:string;revision:string};
type Template={id:number;name:string;groups:number[];revision:string;content?:string};
type Catalog={categories:Category[];fields:MetaField[];templates:Template[];groups:Group[];headquartersId:number;types?:string[]};
type Binding={id:number;keyId:number;sort:number;history:number;name:string;type:string};
type Bindings={category:Category;headquarters:number;fields:Binding[];revision:string};
type Editor={kind:"categories";value:Category|null}|{kind:"fields";value:MetaField|null}|{kind:"templates";value:Template|null};
function ErrorMessage({error}:{error:string}){return error?<div className="alert error" role="alert">{error}</div>:null;}
export function Forms({refresh,onDirtyChange}:{refresh:number;onDirtyChange:(v:boolean)=>void}){
const [catalog,setCatalog]=useState<Catalog|null>(null),[section,setSection]=useState<"categories"|"fields"|"templates">("categories"),[search,setSearch]=useState(""),[editor,setEditor]=useState<Editor|null>(null),[bindings,setBindings]=useState<Category|null>(null),[reload,setReload]=useState(0),[error,setError]=useState(""),[notice,setNotice]=useState(""),[loading,setLoading]=useState(false),[dirty,setDirty]=useState(false);const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
useEffect(()=>{let live=true;setLoading(true);request<Catalog>("/forms").then(c=>{if(live){setCatalog(c);setError("");}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setLoading(false);});return()=>{live=false;};},[refresh,reload]);
async function change(next:typeof section){if(dirty&&!await discard("Ungespeicherte Formulardaten verwerfen?"))return;setEditor(null);setBindings(null);setSection(next);setSearch("");}
const match=(s:string)=>s.toLocaleLowerCase("de").includes(search.toLocaleLowerCase("de"));
return <><ErrorMessage error={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}<div className="administration"><aside className="admin-navigation" aria-label="Formularbereiche"><button className={section==="categories"?"active":""} onClick={()=>void change("categories")}><FileText size={18}/>Formulare</button><button className={section==="fields"?"active":""} onClick={()=>void change("fields")}><List size={18}/>Globale Felder</button><button className={section==="templates"?"active":""} onClick={()=>void change("templates")}><FileText size={18}/>Vorlagen</button></aside><section className="panel admin-panel"><div className="admin-title"><div><h2>{section==="categories"?"Formulare":section==="fields"?"Globale Felder":"Vorlagen"}</h2><p>Definitionen und Vorlagen gelten systemweit. Feldzuordnungen folgen der bestehenden Niederlassungseinstellung.</p></div><button className="button primary" disabled={!catalog} onClick={()=>setEditor({kind:section,value:null} as Editor)}><Plus size={16}/>{section==="categories"?"Neues Formular":section==="fields"?"Neues Feld":"Neue Vorlage"}</button></div><Field label="Anzeigefilter"><input type="search" value={search} onChange={e=>setSearch(e.target.value)} placeholder="Name, Kürzel oder Nummer"/></Field>{loading&&<LoaderCircle className="spin"/>}{catalog&&<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Nr.</th><th>{section==="categories"?"Beschreibung":"Name"}</th><th>{section==="categories"?"Kürzel / Vorlage":section==="fields"?"Feldtyp":"Gruppen"}</th><th>Aktionen</th></tr></thead><tbody>{section==="categories"?catalog.categories.filter(c=>match(`${c.id} ${c.description} ${c.mnemonic}`)).map(c=><tr key={c.id}><td>{c.id}</td><td><strong>{c.description}</strong><small>{c.groups.map(g=>catalog.groups.find(v=>v.id===g)?.label||`Gruppe ${g}`).join(", ")}</small></td><td>{c.mnemonic}<br/>{catalog.templates.find(t=>t.id===c.template)?.name||"Ohne Vorlage"}</td><td><button className="button" onClick={()=>setEditor({kind:"categories",value:c})}>Bearbeiten / Gruppen</button><button className="button" onClick={()=>setBindings(c)}>Inhalt / Felder</button></td></tr>):section==="fields"?catalog.fields.filter(f=>match(`${f.id} ${f.name} ${f.type}`)).map(f=><tr key={f.id}><td>{f.id}</td><td>{f.name}</td><td><code>{f.type}</code></td><td><button className="button" onClick={()=>setEditor({kind:"fields",value:f})}>Bearbeiten</button></td></tr>):catalog.templates.filter(t=>match(`${t.id} ${t.name}`)).map(t=><tr key={t.id}><td>{t.id}</td><td>{t.name}</td><td>{t.groups.map(g=>catalog.groups.find(v=>v.id===g)?.label||`Gruppe ${g}`).join(", ")||"–"}</td><td><button className="button" onClick={()=>setEditor({kind:"templates",value:t})}>Vorlage bearbeiten</button></td></tr>)}</tbody></table></div>}</section></div>
{catalog&&editor&&<MetadataEditor editor={editor} catalog={catalog} onDirty={setDirty} onClose={()=>setEditor(null)} onSaved={()=>{setEditor(null);setReload(v=>v+1);setNotice("Definition gespeichert.");}}/>}
{catalog&&bindings&&<BindingEditor category={bindings} catalog={catalog} onDirty={setDirty} onClose={()=>setBindings(null)} onSaved={()=>{setBindings(null);setReload(v=>v+1);setNotice("Feldzuordnung und Sortierung gespeichert.");}}/>}{dialog}</>;
}
function GroupSelection({available,value,onChange}:{available:Group[];value:number[];onChange:(v:number[])=>void}){return <><h3>Gruppen</h3><div className="message-recipients">{available.map(g=><label className="admin-check" key={g.id}><input type="checkbox" checked={value.includes(g.id)} onChange={e=>onChange(e.target.checked?[...value,g.id]:value.filter(id=>id!==g.id))}/>{g.label}</label>)}{!available.length&&<p>Keine freigegebenen Gruppen vorhanden.</p>}</div></>;}
function MetadataEditor({editor,catalog,onDirty,onClose,onSaved}:{editor:Editor;catalog:Catalog;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const original=editor.value;const [name,setName]=useState(original&&"name" in original?original.name:original&&"description" in original?original.description:""),[mnemonic,setMnemonic]=useState(editor.kind==="categories"?editor.value?.mnemonic||"":""),[template,setTemplate]=useState(editor.kind==="categories"?editor.value?.template||0:0),[groups,setGroups]=useState(original&&"groups" in original?original.groups:[]),[type,setType]=useState(editor.kind==="fields"?editor.value?.type||"varchar":"varchar"),[content,setContent]=useState(""),[loadedContent,setLoadedContent]=useState(""),[revision,setRevision]=useState(original?.revision||null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(editor.kind==="templates"&&!!original),[ready,setReady]=useState(editor.kind!=="templates"||!original);
const [discard,dialog]=useDiscardConfirmation();const initialName=original&&"name" in original?original.name:original&&"description" in original?original.description:"";
const dirty=name!==initialName||mnemonic!==(editor.kind==="categories"?editor.value?.mnemonic||"":"")||template!==(editor.kind==="categories"?editor.value?.template||0:0)||type!==(editor.kind==="fields"?editor.value?.type||"varchar":"varchar")||content!==loadedContent||JSON.stringify(groups)!==JSON.stringify(original&&"groups" in original?original.groups:[]);
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
useEffect(()=>{if(editor.kind!=="templates"||!editor.value)return;let live=true;request<Template>(`/forms/templates/${editor.value.id}`).then(t=>{if(live){setContent(t.content||"");setLoadedContent(t.content||"");setRevision(t.revision);setName(t.name);setGroups(t.groups);setReady(true);}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setLoading(false);});return()=>{live=false;};},[editor]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Änderungen am Formular verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const body=editor.kind==="categories"?{revision,mnemonic,description:name,template,groups}:editor.kind==="fields"?{revision,name,type}:{revision,name,content,groups};if(original)await put(`/forms/${editor.kind}/${original.id}`,body);else await post(`/forms/${editor.kind}`,body);onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const types=[...new Set(["varchar","text","checkbox","date","datetime","time","birthdate","author","co2cs","image",...(catalog.types||[]),...catalog.fields.map(f=>f.type)])].sort();
return <><Modal title={editor.kind==="categories"?"Formular bearbeiten":editor.kind==="fields"?"Globales Feld bearbeiten":"Vorlage bearbeiten"} onClose={close} wide><form className="admin-dialog-body" onSubmit={save}><ErrorMessage error={error}/>{loading&&<LoaderCircle className="spin"/>}<fieldset disabled={busy||loading||!ready}><Field label={editor.kind==="categories"?"Bezeichnung":"Name"}><input required maxLength={editor.kind==="templates"?50:editor.kind==="categories"?1000:100} value={name} onChange={e=>setName(e.target.value)}/></Field>{editor.kind==="categories"&&<><div className="admin-toolbar"><Field label="Kürzel"><input required maxLength={30} value={mnemonic} onChange={e=>setMnemonic(e.target.value)}/></Field><Field label="Vorlage"><select value={template} onChange={e=>setTemplate(Number(e.target.value))}><option value={0}>Ohne Vorlage</option>{catalog.templates.map(t=><option key={t.id} value={t.id}>{t.name} (#{t.id})</option>)}</select></Field></div><GroupSelection available={catalog.groups} value={groups} onChange={setGroups}/></>}
{editor.kind==="fields"&&<><Field label="Feldtyp"><input required maxLength={50} list="form-field-types" value={type} onChange={e=>setType(e.target.value)}/></Field><datalist id="form-field-types">{types.map(t=><option key={t} value={t}/>)}</datalist><p className="form-hint">Kombinierte Felder: ref:101:102 verknüpft beispielsweise die Feldnummern 101 und 102. Änderungen wirken in allen Formularen, die dieses Feld verwenden.</p></>}
{editor.kind==="templates"&&<><GroupSelection available={catalog.groups} value={groups} onChange={setGroups}/><Field label="Vorlageninhalt"><textarea spellCheck={false} rows={12} className="template-source" value={content} onChange={e=>setContent(e.target.value)}/></Field><p className="form-hint">HTML und vorhandene Platzhalter, beispielsweise [-501,3,100-], werden unverändert gespeichert. {new TextEncoder().encode(content).length.toLocaleString("de-DE")} / 65.535 Bytes</p></>}
<div className="admin-row-actions"><button className="button" type="button" onClick={close}>Abbrechen</button><button className="button primary" disabled={!dirty}>Speichern</button></div></fieldset></form></Modal>{dialog}</>;
}
function BindingEditor({category,catalog,onDirty,onClose,onSaved}:{category:Category;catalog:Catalog;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const [data,setData]=useState<Bindings|null>(null),[keys,setKeys]=useState<number[]>([]),[adding,setAdding]=useState(0),[q,setQ]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false);const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{let live=true;request<Bindings>(`/forms/categories/${category.id}/fields`).then(d=>{if(live){setData(d);setKeys(d.fields.map(f=>f.keyId));}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[category.id]);
const dirty=!!data&&JSON.stringify(keys)!==JSON.stringify(data.fields.map(f=>f.keyId));useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Feldzuordnung verwerfen?")))onClose();}
async function save(){if(!data)return;setBusy(true);setError("");try{await put(`/forms/categories/${category.id}/fields`,{revision:data.revision,keys});onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function move(index:number,next:number){setKeys(v=>{const a=[...v];[a[index],a[next]]=[a[next],a[index]];return a;});}
return <><Modal title={`Felder: ${category.description}`} onClose={close} wide><div className="admin-dialog-body"><ErrorMessage error={error}/>{data?<><p><strong>Geltungsbereich:</strong> {data.headquarters?"Eigene Niederlassung":"Global für alle Niederlassungen"}</p><fieldset disabled={busy}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Reihenfolge</th><th>Feld</th><th>Typ / Historie</th><th>Verschieben</th></tr></thead><tbody>{keys.map((id,index)=>{const f=catalog.fields.find(f=>f.id===id),old=data.fields.find(f=>f.keyId===id);return <tr key={id}><td>{index+1}</td><td>{f?.name||old?.name||`Feld ${id}`}<small>#{id}</small></td><td>{f?.type||old?.type}{old?.history===1&&<small>Feldhistorie aktiviert</small>}</td><td><button type="button" className="icon-button" aria-label={`Feld ${id} nach oben`} disabled={!index} onClick={()=>move(index,index-1)}><ArrowUp size={16}/></button><button type="button" className="icon-button" aria-label={`Feld ${id} nach unten`} disabled={index===keys.length-1} onClick={()=>move(index,index+1)}><ArrowDown size={16}/></button>{!old&&<button type="button" className="button" onClick={()=>setKeys(v=>v.filter(k=>k!==id))}>Hinzufügen zurücknehmen</button>}</td></tr>;})}</tbody></table></div><h3>Globales Feld hinzufügen</h3><div className="admin-toolbar"><Field label="Feld suchen"><input value={q} onChange={e=>setQ(e.target.value)}/></Field><Field label="Feld auswählen"><select value={adding} onChange={e=>setAdding(Number(e.target.value))}><option value={0}>Bitte auswählen</option>{catalog.fields.filter(f=>!keys.includes(f.id)&&`${f.id} ${f.name}`.toLowerCase().includes(q.toLowerCase())).map(f=><option key={f.id} value={f.id}>{f.name} (#{f.id})</option>)}</select></Field><button type="button" className="button" disabled={!adding||keys.includes(adding)} onClick={()=>{setKeys(v=>[...v,adding]);setAdding(0);}}>Hinzufügen</button></div><div className="admin-row-actions"><button className="button" onClick={close}>Abbrechen</button><button className="button primary" disabled={!dirty} onClick={()=>void save()}>Zuordnung und Sortierung speichern</button></div></fieldset></>:!error&&<LoaderCircle className="spin"/>}</div></Modal>{dialog}</>;
}
+12
View File
@@ -0,0 +1,12 @@
import {useEffect,useRef,useState} from "react";
import {request} from "./api";
import {Field} from "./FormElements";
type Suggestion={id:string;label:string};
type Address={street:string;houseNumber:string;postalCode:string;city:string;country:string};
export function MasterAddressLookup({kind,prefix,country,disabled,addressVersion,onSelect}:{addressVersion:string;kind:string;prefix:string;country:string;disabled:boolean;onSelect:(patch:Record<string,string>)=>void}) {
const [query,setQuery]=useState(""),[items,setItems]=useState<Suggestion[]>([]),[error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false);
const session=useRef(crypto.randomUUID()),sequence=useRef(0);
useEffect(()=>{const seq=++sequence.current;setItems([]);setError("");setLoading(false);if(disabled||query.trim().length<3)return;const timer=setTimeout(()=>{setLoading(true);request<Suggestion[]>(`/master-data/${kind}/address-suggestions?`+new URLSearchParams({q:query,country:country==="D"?"DE":country,sessionToken:session.current})).then(rows=>{if(sequence.current===seq)setItems(rows);}).catch(e=>{if(sequence.current===seq)setError(e.message);}).finally(()=>{if(sequence.current===seq)setLoading(false);});},350);return()=>{++sequence.current;clearTimeout(timer);};},[query,country,kind,disabled,addressVersion]);
async function select(id:string){if(!id||busy||disabled)return;const seq=++sequence.current;setBusy(true);setError("");try{const address=await request<Address>(`/master-data/${kind}/address-details?`+new URLSearchParams({placeId:id,sessionToken:session.current}));if(seq!==sequence.current)return;onSelect({[prefix+"ad_street"]:address.street,[prefix+(prefix?"cscad_hsno":"cmp_hsno")]:address.houseNumber,[prefix+"ad_zipcode"]:address.postalCode,[prefix+"ad_city"]:address.city,[prefix+"ad_country"]:address.country});setQuery("");setItems([]);session.current=crypto.randomUUID();}catch(e){if(seq===sequence.current)setError((e as Error).message);}finally{setBusy(false);}}
return <div className="master-address-lookup"><Field label="Adresse suchen"><input type="search" value={query} maxLength={200} disabled={disabled||busy} placeholder="Straße, Hausnummer und Ort …" autoComplete="off" onChange={e=>setQuery(e.target.value)} onKeyDown={e=>{if(e.key==="Enter")e.preventDefault();}}/></Field>{loading||busy?<p role="status" className="form-hint">{busy?"Adresse wird übernommen …":"Adressvorschläge werden gesucht …"}</p>:items.length>0?<><select aria-label="Adressvorschlag auswählen" value="" disabled={disabled} onChange={e=>void select(e.target.value)}><option value="">Vorschlag auswählen …</option>{items.map(item=><option key={item.id} value={item.id}>{item.label}</option>)}</select><small className="maps-attribution">Google Maps</small></>:query.trim().length>=3&&!error?<p className="form-hint">Keine Vorschläge. Suche ergänzen oder Adresse manuell eintragen.</p>:null}{error&&<p role="alert" className="form-hint">{error} Manuelle Eingabe bleibt möglich.</p>}</div>;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState, type FormEvent } from "react";
import { LoaderCircle, MessageSquare, Radio, Plus, Send } from "lucide-react";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { request, post, put } from "./api";
import { Newsletters } from "./Newsletters";
type Option={id:number;label:string;headquarters?:number;device?:string;available?:string};
type Options={newsletters?:boolean;devices:boolean;bulletins:boolean;manageGroups:boolean;allActive:boolean;today:string;headquartersId:number;headquarters:Option[];employees:Option[];maxBulletin:number;maxMessage:number};
type Group={id:number;name:string;groups:number[];revision:string};
type Member=Option&{member:boolean;revision:string};
type Bulletin={id:number;created:string;from:string;to:string;text:string;sender:string;senderId:number;recipient:string;headquarters:string;removable:boolean;revision:string};
type Message={id:number;created:string;delivery:string|null;acknowledged:string|null;state:number;priority:number;sender:string;recipient:string;reader:string;answerer:string;subject:string;body:string;replyTo:number;canRead:boolean;canReply:boolean;revision:string};
type Preview={token:string;recipients:Option[];subject:string;body:string;replyTo:number};
type SendResult={queued:number;total:number;uncertain:number;unattempted:number;warning:string};
type Page<T>={total:number;items:T[]};
const when=(v:string|null)=>v?new Date(v).toLocaleString("de-DE"):"–";
const states=["Noch nicht ausgeliefert","Ausgeliefert, ungelesen","Gelesen","Beantwortet"];
function ErrorMessage({error}:{error:string}){return error?<div className="alert error" role="alert">{error}</div>:null;}
function Pages({page,total,onChange,busy=false}:{page:number;total:number;onChange:(page:number)=>void;busy?:boolean}){return <div className="admin-toolbar"><span>{total} Einträge · Seite {page+1} von {Math.max(1,Math.ceil(total/100))}</span><button className="button" disabled={!page||busy} onClick={()=>onChange(page-1)}>Zurück</button><button className="button" disabled={(page+1)*100>=total||busy} onClick={()=>onChange(page+1)}>Weiter</button></div>;}
export function Messages({refresh,onDirtyChange}:{refresh:number;onDirtyChange:(dirty:boolean)=>void}){
const [options,setOptions]=useState<Options|null>(null),[error,setError]=useState(""),[view,setView]=useState(""),[dirty,setDirty]=useState(false);
const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{let live=true;request<Options>("/messages/options").then(o=>{if(live){setOptions(o);setError("");}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[refresh]);
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
const active=view||(options?.devices?"devices":options?.bulletins?"bulletins":"newsletters");
return <><ErrorMessage error={error}/>{options?<div className="administration"><aside className="admin-navigation" aria-label="Nachrichtenbereiche">{options.devices&&<button className={active==="devices"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherte Nachricht verwerfen?"))setView("devices");}}><Radio size={18}/>Endgeräte</button>}{options.bulletins&&<button className={active==="bulletins"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherte Nachricht verwerfen?"))setView("bulletins");}}><MessageSquare size={18}/>Mitteilungen</button>}{options.newsletters&&<button className={active==="newsletters"?"active":""} onClick={async()=>{if(!dirty||await discard("Ungespeicherten Newsletter verwerfen?"))setView("newsletters");}}><MessageSquare size={18}/>Newsletter</button>}</aside><section className="panel admin-panel" key={`${active}:${refresh}`}>{active==="devices"?<DeviceMessages options={options} onDirty={setDirty}/>:active==="bulletins"?<Bulletins options={options} onDirty={setDirty}/>:<Newsletters onDirty={setDirty}/>}</section></div>:!error&&<LoaderCircle className="spin"/>}{dialog}</>;
}
function Bulletins({options,onDirty}:{options:Options;onDirty:(v:boolean)=>void}){
const [data,setData]=useState<Page<Bulletin>>({items:[],total:0}),[page,setPage]=useState(0),[expired,setExpired]=useState(false),[reload,setReload]=useState(0),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[editor,setEditor]=useState<{employee:number}|null>(null),[removing,setRemoving]=useState<Bulletin|null>(null);
useEffect(()=>{let live=true;setBusy(true);request<Page<Bulletin>>(`/messages/bulletins?expired=${expired}&page=${page}`).then(d=>{if(live){setData(d);setError("");}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[expired,page,reload]);
async function remove(){if(!removing)return;setBusy(true);try{await request(`/messages/bulletins/${removing.id}`,{method:"DELETE",body:JSON.stringify({revision:removing.revision})});setRemoving(null);setReload(v=>v+1);setNotice("Mitteilung entfernt.");}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><div className="admin-title"><div><h2>Mitteilungen</h2><p>Nachrichten an Mitarbeiter und Niederlassungen mit Gültigkeitszeitraum.</p></div><button className="button primary" onClick={()=>setEditor({employee:0})}><Plus size={16}/>Neue Mitteilung</button></div><ErrorMessage error={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}<label className="admin-check"><input type="checkbox" checked={expired} onChange={e=>{setExpired(e.target.checked);setPage(0);}}/>Abgelaufene Mitteilungen anzeigen</label>{busy&&<LoaderCircle className="spin"/>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Gültigkeit</th><th>Absender / Empfänger</th><th>Mitteilung</th><th>Aktionen</th></tr></thead><tbody>{data.items.map(b=><tr key={b.id}><td>{when(b.from)}<br/>bis {when(b.to)}<small>Erstellt: {when(b.created)}</small></td><td>{b.sender||"Unbekannt"}<br/>An: {b.recipient||b.headquarters||"Alle"}</td><td className="message-text">{b.text}</td><td><button className="button" disabled={!options.employees.some(e=>e.id===b.senderId)} onClick={()=>setEditor({employee:b.senderId})}>Antworten</button>{b.removable&&<button className="button danger" onClick={()=>setRemoving(b)}>Entfernen</button>}</td></tr>)}</tbody></table>{!busy&&!data.items.length&&<p>Keine Mitteilungen vorhanden.</p>}</div><Pages page={page} total={data.total} onChange={setPage} busy={busy}/>
{editor&&<BulletinEditor options={options} employee={editor.employee} onDirty={onDirty} onClose={()=>setEditor(null)} onSaved={()=>{setEditor(null);setReload(v=>v+1);setNotice("Mitteilung veröffentlicht.");}}/>}
{removing&&<Modal title="Mitteilung entfernen" onClose={()=>{if(!busy)setRemoving(null);}}><div className="admin-dialog-body"><p>Diese Mitteilung dauerhaft entfernen?</p><p className="message-text">{removing.text}</p><ErrorMessage error={error}/><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Entfernen</button></div></div></Modal>}</>;
}
function BulletinEditor({options,employee,onDirty,onClose,onSaved}:{options:Options;employee:number;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const [text,setText]=useState(""),[from,setFrom]=useState(options.today),[to,setTo]=useState(options.today),[hq,setHq]=useState(options.headquartersId),[recipient,setRecipient]=useState(employee),[error,setError]=useState(""),[busy,setBusy]=useState(false),[preview,setPreview]=useState(false);const [discard,dialog]=useDiscardConfirmation();
const dirty=!!text||from!==options.today||to!==options.today||hq!==options.headquartersId||recipient!==employee;
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Mitteilung verwerfen?")))onClose();}
async function send(){setBusy(true);setError("");try{await post("/messages/bulletins",{from,to,headquarters:hq,employee:recipient,text});onSaved();}catch(e){setError((e as Error).message);setPreview(false);}finally{setBusy(false);}}
return <><Modal title={employee?"Auf Mitteilung antworten":"Neue Mitteilung"} onClose={close} wide><form className="admin-dialog-body" onSubmit={e=>{e.preventDefault();setPreview(true);}}><ErrorMessage error={error}/><fieldset disabled={busy||preview}><div className="admin-toolbar"><Field label="Gültig von"><input type="date" required value={from} onInput={e=>setFrom(e.currentTarget.value)}/></Field><Field label="Gültig bis"><input type="date" required min={from>options.today?from:options.today} value={to} onInput={e=>setTo(e.currentTarget.value)}/></Field><Field label="Niederlassung"><select value={hq} disabled={recipient>0} onChange={e=>setHq(Number(e.target.value))}>{options.headquarters.length>1&&<option value={0}>Alle freigegebenen Niederlassungen</option>}{options.headquarters.map(h=><option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><Field label="Empfänger"><select value={recipient} onChange={e=>setRecipient(Number(e.target.value))}><option value={0}>Alle Mitarbeiter der Niederlassung</option>{options.employees.map(p=><option key={p.id} value={p.id}>{p.label} [{options.headquarters.find(h=>h.id===p.headquarters)?.label}]</option>)}</select></Field></div><Field label="Mitteilung"><textarea required rows={6} maxLength={options.maxBulletin} value={text} onChange={e=>setText(e.target.value)}/></Field><p className="form-hint">{text.length} / {options.maxBulletin} Zeichen</p><div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Mitteilung prüfen</button></div></fieldset></form></Modal>{preview&&<Modal title="Mitteilung veröffentlichen" onClose={()=>{if(!busy)setPreview(false);}}><div className="admin-dialog-body"><p><strong>An:</strong> {recipient?options.employees.find(e=>e.id===recipient)?.label:hq?options.headquarters.find(h=>h.id===hq)?.label:"Alle freigegebenen Niederlassungen"}</p><p>{from} bis {to}</p><p className="message-text">{text}</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setPreview(false)}>Zurück</button><button className="button primary" disabled={busy} onClick={()=>void send()}>Jetzt veröffentlichen</button></div></div></Modal>}{dialog}</>;
}
function DeviceMessages({options,onDirty}:{options:Options;onDirty:(v:boolean)=>void}){
const initial={from:options.today,to:options.today,states:[0,1],maxPriority:3,direction:"EXCLUDE_HQ",courier:null as number|null,page:0};
const [filter,setFilter]=useState(initial),[query,setQuery]=useState(initial),[data,setData]=useState<Page<Message>>({items:[],total:0}),[groups,setGroups]=useState<Group[]>([]),[reload,setReload]=useState(0),[error,setError]=useState(""),[busy,setBusy]=useState(false),[composer,setComposer]=useState<Message|"new"|null>(null),[groupEditor,setGroupEditor]=useState<Group|"new"|null>(null),[members,setMembers]=useState<Group|null>(null),[removing,setRemoving]=useState<Group|null>(null),[courier,setCourier]=useState<Option|null>(null);
useEffect(()=>{let live=true;setBusy(true);Promise.all([post<Page<Message>>("/messages/history",query),request<Group[]>("/messages/groups")]).then(([d,g])=>{if(live){setData(d);setGroups(g);setError("");}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[query,reload]);
async function read(m:Message){setBusy(true);try{await post(`/messages/${m.id}/read`,{revision:m.revision});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function remove(){if(!removing)return;setBusy(true);try{await request(`/messages/groups/${removing.id}`,{method:"DELETE",body:JSON.stringify({revision:removing.revision})});setRemoving(null);setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><div className="admin-title"><div><h2>Endgeräte und Nachrichten</h2><p>Nachrichtenhistorie, Empfangsbestätigungen und Versand an Transporteure.</p></div><button className="button primary" onClick={()=>setComposer("new")}><Send size={16}/>Neue Nachricht</button></div><ErrorMessage error={error}/>
<form onSubmit={e=>{e.preventDefault();setQuery({...filter,courier:courier?.id||null,page:0});}}><div className="admin-toolbar"><Field label="Von"><input type="date" required value={filter.from} onInput={e=>{const value=e.currentTarget.value;setFilter(v=>({...v,from:value}));}}/></Field><Field label="Bis"><input type="date" required value={filter.to} min={filter.from} onInput={e=>{const value=e.currentTarget.value;setFilter(v=>({...v,to:value}));}}/></Field><Field label="Nachrichtenart"><select value={filter.direction} onChange={e=>setFilter(v=>({...v,direction:e.target.value}))}><option value="ALL">Alle Nachrichten</option><option value="EXCLUDE_HQ">Zentralennachrichten ausblenden</option><option value="ONLY_HQ">Nur Zentralennachrichten</option></select></Field><Field label="Maximale Prioritätsstufe"><select value={filter.maxPriority} onChange={e=>setFilter(v=>({...v,maxPriority:Number(e.target.value)}))}>{[0,1,2,3].map(n=><option key={n}>{n}</option>)}</select></Field><button className="button primary" disabled={busy||!filter.states.length}>Anzeigen</button></div><div className="admin-toolbar">{states.map((s,i)=><label key={s} className="admin-check"><input type="checkbox" checked={filter.states.includes(i)} onChange={e=>setFilter(v=>({...v,states:e.target.checked?[...v.states,i]:v.states.filter(n=>n!==i)}))}/>{s}</label>)}</div><CourierSearch label="Transporteur / Endgerät filtern" value={courier} onSelect={setCourier}/></form>
{busy&&<LoaderCircle className="spin"/>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Zeit / Status</th><th>Absender / Empfänger</th><th>Nachricht</th><th>Aktionen</th></tr></thead><tbody>{data.items.map(m=><tr key={m.id}><td>#{m.id} · {when(m.created)}<br/>{states[m.state]||m.state}<small>Zustellung: {when(m.delivery)}<br/>Bestätigung: {when(m.acknowledged)}{m.reader&&<><br/>Gelesen von: {m.reader}</>}{m.answerer&&<><br/>Beantwortet von: {m.answerer}</>}</small></td><td>{m.sender||"Unbekannt"}<br/>→ {m.recipient||"Zentrale"}</td><td><strong>{m.subject}</strong><p className="message-text">{m.body}</p>{m.replyTo>0&&<small>Antwort auf #{m.replyTo}</small>}</td><td>{m.canRead&&<button className="button" disabled={busy} onClick={()=>void read(m)}>Als gelesen markieren</button>}{m.canReply&&<button className="button" disabled={busy} onClick={()=>setComposer(m)}>Antworten</button>}</td></tr>)}</tbody></table>{!busy&&!data.items.length&&<p>Keine Nachrichten für diese Filter.</p>}</div><Pages page={query.page} total={data.total} onChange={p=>setQuery(v=>({...v,page:p}))} busy={busy}/>
<div className="admin-title"><h3>Nachrichtengruppen</h3>{options.manageGroups&&<button className="button" onClick={()=>setGroupEditor("new")}>Neue Nachrichtengruppe</button>}</div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Gruppe</th><th>Zugeordnete Gruppen</th>{options.manageGroups&&<th>Aktionen</th>}</tr></thead><tbody>{groups.map(g=><tr key={g.id}><td>{g.name}</td><td>{g.groups.length}</td>{options.manageGroups&&<td><button className="button" onClick={()=>setGroupEditor(g)}>Bearbeiten</button><button className="button" onClick={()=>setMembers(g)}>Zuordnungsliste</button><button className="button danger" onClick={()=>setRemoving(g)}>Löschen</button></td>}</tr>)}</tbody></table></div>
{composer&&<MessageComposer options={options} groups={groups} reply={composer==="new"?null:composer} onDirty={onDirty} onClose={()=>{setComposer(null);setReload(v=>v+1);}}/>}
{groupEditor&&<MessageGroupEditor group={groupEditor} onDirty={onDirty} onClose={()=>setGroupEditor(null)} onSaved={()=>{setGroupEditor(null);setReload(v=>v+1);}}/>}
{members&&<MessageGroupMembers group={members} onClose={()=>{setMembers(null);setReload(v=>v+1);}}/>}
{removing&&<Modal title="Nachrichtengruppe löschen" onClose={()=>{if(!busy)setRemoving(null);}}><div className="admin-dialog-body"><p>„{removing.name}“ und ihre Transporteurszuordnungen dauerhaft entfernen?</p><ErrorMessage error={error}/><div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>Gruppe löschen</button></div></div></Modal>}</>;
}
function CourierSearch({label,value,onSelect}:{label:string;value:Option|null;onSelect:(v:Option|null)=>void}){
const [q,setQ]=useState(""),[items,setItems]=useState<Option[]>([]),[error,setError]=useState("");
useEffect(()=>{if(value)return;let live=true;const timer=setTimeout(()=>request<Option[]>(`/messages/couriers?q=${encodeURIComponent(q)}`).then(d=>{if(live){setItems(d);setError("");}}).catch(e=>{if(live)setError(e.message);}),250);return()=>{live=false;clearTimeout(timer);};},[q,value]);
return <div className="message-lookup"><ErrorMessage error={error}/>{value?<div className="admin-toolbar"><strong>{label}: {value.label}</strong>{value.device&&<span>Endgerät: {value.device}</span>}<button className="button" type="button" onClick={()=>onSelect(null)}>Auswahl ändern</button></div>:<div className="admin-toolbar"><Field label={label}><input type="search" value={q} placeholder="Fahrzeugnummer oder Name" onChange={e=>setQ(e.target.value)}/></Field><Field label="Treffer auswählen"><select value="" onChange={e=>onSelect(items.find(v=>v.id===Number(e.target.value))||null)}><option value="">Keine Auswahl</option>{items.map(c=><option key={c.id} value={c.id}>{c.label}{c.device?` · ${c.device}`:""}</option>)}</select></Field><small>Bis zu 100 Treffer; Suche bei Bedarf eingrenzen.</small></div>}</div>;
}
function MessageComposer({options,groups,reply,onDirty,onClose}:{options:Options;groups:Group[];reply:Message|null;onDirty:(v:boolean)=>void;onClose:()=>void}){
const [subject,setSubject]=useState(reply?.subject?`AW: ${reply.subject}`.slice(0,40):""),[body,setBody]=useState(""),[mode,setMode]=useState("courier"),[courier,setCourier]=useState<Option|null>(null),[selected,setSelected]=useState<number[]>([]),[preview,setPreview]=useState<Preview|null>(null),[result,setResult]=useState<SendResult|null>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false);const [discard,dialog]=useDiscardConfirmation();
const dirty=!result&&!!(body||subject||courier||selected.length);useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Nachricht verwerfen?")))onClose();}
async function prepare(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{setPreview(await post<Preview>("/messages/preview",{subject,body,courier:!reply&&mode==="courier"?courier?.id:null,groups:!reply&&mode==="groups"?selected:[],allActive:!reply&&mode==="all",replyTo:reply?.id||null,revision:reply?.revision||null}));setAttempted(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function send(){if(!preview)return;setBusy(true);setAttempted(true);setError("");try{setResult(await post<SendResult>(`/messages/send/${preview.token}`));}catch(e){setError(`${(e as Error).message} Die Übergabe ist möglicherweise schon erfolgt. „Ergebnis abrufen“ verwendet denselben Versandauftrag.`);}finally{setBusy(false);}}
return <><Modal title={reply?`Antwort auf Nachricht #${reply.id}`:"Neue Endgerätenachricht"} onClose={close} wide><div className="admin-dialog-body"><ErrorMessage error={error}/>{result?<><h3>{result.queued} von {result.total} Nachrichten übergeben</h3><p>Die Historie zeigt den weiteren Zustell- und Lesestatus.</p>{result.warning&&<div className="alert error" role="alert">{result.warning}</div>}{(result.uncertain>0||result.unattempted>0)&&<p>{result.uncertain} unbestätigte Übergabe · {result.unattempted} Empfänger noch nicht versucht</p>}<button className="button primary" onClick={onClose}>Zur Historie</button></>:preview?<><p><strong>{preview.recipients.length} Empfänger</strong></p><div className="message-recipients">{preview.recipients.map(p=><div key={p.id}>{p.label}</div>)}</div><h3>{preview.subject||"Ohne Betreff"}</h3><p className="message-text">{preview.body}</p><p className="form-hint">Mit „Jetzt senden“ wird die Nachricht zur Zustellung an diese Endgeräte übergeben.</p><div className="admin-row-actions"><button className="button" disabled={busy||attempted} onClick={()=>setPreview(null)}>Bearbeiten</button><button className="button primary" disabled={busy} onClick={()=>void send()}>{attempted?"Ergebnis abrufen":"Jetzt senden"}</button></div></>:<form onSubmit={prepare}><fieldset disabled={busy}>{!reply&&<><Field label="Empfängerauswahl"><select value={mode} onChange={e=>setMode(e.target.value)}><option value="courier">Einzelner Transporteur</option><option value="groups">Nachrichtengruppen</option>{options.allActive&&<option value="all">Alle aktiven Fahrer der freigegebenen Niederlassungen</option>}</select></Field>{mode==="courier"&&<CourierSearch label="Transporteur" value={courier} onSelect={setCourier}/>} {mode==="groups"&&<div className="admin-toolbar">{groups.map(g=><label className="admin-check" key={g.id}><input type="checkbox" checked={selected.includes(g.id)} onChange={e=>setSelected(v=>e.target.checked?[...v,g.id]:v.filter(id=>id!==g.id))}/>{g.name}</label>)}</div>}</>}<Field label="Betreff"><input maxLength={40} value={subject} onChange={e=>setSubject(e.target.value)}/></Field><Field label="Nachricht"><textarea required rows={6} maxLength={options.maxMessage} value={body} onChange={e=>setBody(e.target.value)}/></Field><p className="form-hint">{body.length} / {options.maxMessage} Zeichen</p><div className="admin-row-actions"><button className="button" type="button" onClick={close}>Abbrechen</button><button className="button primary">Empfänger und Nachricht prüfen</button></div></fieldset></form>}</div></Modal>{dialog}</>;
}
function MessageGroupEditor({group,onDirty,onClose,onSaved}:{group:Group|"new";onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:()=>void}){
const [name,setName]=useState(group==="new"?"":group.name),[selected,setSelected]=useState(group==="new"?[]:group.groups),[available,setAvailable]=useState<Option[]>([]),[error,setError]=useState(""),[busy,setBusy]=useState(false);const [discard,dialog]=useDiscardConfirmation();const dirty=name!==(group==="new"?"":group.name)||JSON.stringify(selected)!==JSON.stringify(group==="new"?[]:group.groups);
useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);useEffect(()=>{let live=true;request<Option[]>("/messages/generic-groups").then(v=>{if(live)setAvailable(v);}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[]);
async function close(){if(!busy&&(!dirty||await discard("Ungespeicherte Gruppendaten verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const body={name,groups:selected,revision:group==="new"?null:group.revision};if(group==="new")await post("/messages/groups",body);else await put(`/messages/groups/${group.id}`,body);onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <><Modal title={group==="new"?"Neue Nachrichtengruppe":"Nachrichtengruppe bearbeiten"} onClose={close}><form className="admin-dialog-body" onSubmit={save}><ErrorMessage error={error}/><fieldset disabled={busy}><Field label="Bezeichnung"><input required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></Field><h3>Zugeordnete Gruppen</h3><p className="form-hint">Transporteure dieser Gruppen und ihrer Untergruppen werden bei der Empfängerauswahl berücksichtigt.</p><div className="message-recipients">{available.map(g=><label className="admin-check" key={g.id}><input type="checkbox" checked={selected.includes(g.id)} onChange={e=>setSelected(v=>e.target.checked?[...v,g.id]:v.filter(id=>id!==g.id))}/>{g.label}</label>)}</div><div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Speichern</button></div></fieldset></form></Modal>{dialog}</>;
}
function MessageGroupMembers({group,onClose}:{group:Group;onClose:()=>void}){
const [items,setItems]=useState<Member[]>([]),[q,setQ]=useState(""),[only,setOnly]=useState(true),[busy,setBusy]=useState(false),[reload,setReload]=useState(0),[error,setError]=useState("");
useEffect(()=>{let live=true;setBusy(true);request<Member[]>(`/messages/groups/${group.id}/members`).then(v=>{if(live)setItems(v);}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setBusy(false);});return()=>{live=false;};},[group.id,reload]);
async function change(m:Member){setBusy(true);setError("");try{await put(`/messages/groups/${group.id}/members/${m.id}`,{revision:m.revision,member:!m.member});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title={`Zuordnungsliste: ${group.name}`} onClose={()=>{if(!busy)onClose();}} wide><div className="admin-dialog-body"><ErrorMessage error={error}/><div className="admin-toolbar"><Field label="Transporteur suchen"><input value={q} onChange={e=>setQ(e.target.value)}/></Field><label className="admin-check"><input type="checkbox" checked={only} onChange={e=>setOnly(e.target.checked)}/>Nur direkt zugeordnete Transporteure</label></div><p className="form-hint">Zusätzliche Empfänger aus verknüpften Gruppen werden in der Versandvorschau angezeigt.</p><div className="message-recipients"><table className="admin-table"><tbody>{items.filter(i=>(!only||i.member)&&i.label.toLowerCase().includes(q.toLowerCase())).map(m=><tr key={m.id}><td>{m.label}</td><td><button className="button" disabled={busy} onClick={()=>void change(m)}>{m.member?"Zuordnung entfernen":"Zuordnen"}</button></td></tr>)}</tbody></table></div><button className="button" disabled={busy} onClick={onClose}>Schließen</button></div></Modal>;
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Field, Modal, useDiscardConfirmation } from './FormElements';
import { request, post } from './api';
type Option={id:number;label:string};type Status={started:boolean;running:boolean;sent:number;uncertain:number;unattempted:number;total:number;warning:string};
type Options={bulk:boolean;headquarters:Option[];branches:Option[];groups:Option[];jobs:{token:string;newsletter:number;subject:string;status:Status}[]};
type Preview={token:string;from:string;replyTo:string;subject:string;recipients:{address:string;label:string}[];cc:string[];bcc:string[];personalized:boolean;html:string};
export function NewsletterMail({id,headquarters,onDirty,onClose,endpoint='/newsletters',singular='Newsletter'}:{endpoint?:string;singular?:string;id:number;headquarters:number;onDirty:(v:boolean)=>void;onClose:()=>void}){
const [options,setOptions]=useState<Options|null>(null),[mode,setMode]=useState('manual'),[external,setExternal]=useState(''),[subject,setSubject]=useState(''),[hqs,setHqs]=useState([headquarters]),[types,setTypes]=useState<number[]>([0]),[branches,setBranches]=useState<number[]>([]),[groups,setGroups]=useState<number[]>([]),[cc,setCc]=useState(''),[bcc,setBcc]=useState(''),[copies,setCopies]=useState(false),[preview,setPreview]=useState<Preview|null>(null),[token,setToken]=useState(''),[status,setStatus]=useState<Status|null>(null),[attempted,setAttempted]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState('');const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{let live=true;request<Options>(`${endpoint}/mail/options`).then(o=>{if(live)setOptions(o);}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[endpoint]);
const dirty=busy||(!status&&(!!subject||!!external||!!cc||!!bcc));useEffect(()=>{onDirty(dirty);return()=>onDirty(false);},[dirty,onDirty]);
useEffect(()=>{if(!token||!status?.running)return;let live=true;const timer=setInterval(()=>request<Status>(`${endpoint}/mail/${token}`).then(s=>{if(live){setStatus(s);setError('');}}).catch(e=>{if(live)setError(e.message);}),2000);return()=>{live=false;clearInterval(timer);};},[token,status?.running,endpoint]);
async function close(){if(!busy&&(!dirty||await discard('Ungespeicherten Newsletter-Versand verwerfen?')))onClose();}
async function prepare(e:FormEvent){e.preventDefault();setBusy(true);setError('');try{const p=await post<Preview>(`${endpoint}/${id}/mail/preview`,{headquarters,mode,headquartersSelection:hqs,external,customerTypes:types,branches,groups,cc,bcc,copiesEveryMail:copies,subject});setPreview(p);setToken(p.token);setAttempted(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function send(){if(!token)return;setBusy(true);setAttempted(true);setError('');try{setStatus(await post<Status>(`${endpoint}/mail/${token}/send`));}catch(e){setError(`${(e as Error).message} Bei Verbindungsabbruch den bisherigen Versandstatus abrufen, bevor Sie einen neuen Versand vorbereiten.`);}finally{setBusy(false);}}
async function show(token:string){setBusy(true);setError('');try{setStatus(await request<Status>(`${endpoint}/mail/${token}`));setToken(token);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function toggle(list:number[],id:number,checked:boolean){return checked?[...list,id]:list.filter(n=>n!==id);}
return <><Modal title={`${singular} #${id} per E-Mail`} onClose={close} wide><div className="admin-dialog-body">{error&&<div className="alert error" role="alert">{error}</div>}{status?<><h3>{status.running?'Versand läuft':'Versand beendet'}</h3><p>{status.sent} von {status.total} Nachrichten vom SMTP-Server angenommen.</p><p>{status.uncertain} unbestätigte Übergaben · {status.unattempted} noch nicht versucht</p>{status.warning&&<div className="alert error">{status.warning}</div>}<p className="form-hint">Der laufende Versand kann in diesem Dialog später erneut aufgerufen werden. Nach einem Serverneustart sind die SMTP-Protokolle maßgeblich.</p><button className="button" disabled={busy} onClick={()=>void show(token)}>Status aktualisieren</button><button className="button primary" onClick={onClose}>Schließen</button></>:preview?<><p><strong>{preview.recipients.length} Einzelmails</strong> · Absender: {preview.from} · Antworten an: {preview.replyTo}</p><h3>{preview.subject}</h3><div className="message-recipients">{preview.recipients.map((r,i)=><p key={i}>{r.label} · {r.address}</p>)}</div>{preview.cc.length>0&&<p>Kopie bei jeder Mail: {preview.cc.join(', ')}</p>}{preview.bcc.length>0&&<p>Blindkopie bei jeder Mail: {preview.bcc.join(', ')}</p>}{preview.personalized&&<p>Abmeldelinks werden für jeden Empfänger aus seinem Bestandsdatensatz eingesetzt.</p>}<iframe title="E-Mail-Inhaltsvorschau" className="newsletter-preview" sandbox="" srcDoc={`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'">${preview.html}`}/><div className="admin-row-actions"><button className="button" disabled={busy||attempted} onClick={()=>setPreview(null)}>Bearbeiten</button><button className="button primary" disabled={busy} onClick={()=>void send()}>{attempted?'Bisherigen Versand abrufen':`${singular} jetzt senden`}</button></div></>:<>{options?.jobs.filter(j=>j.newsletter===id).length? <section><h3>Bisherige Versandaufträge</h3>{options.jobs.filter(j=>j.newsletter===id).map(j=><p key={j.token}>{j.subject} · {j.status.sent}/{j.status.total} <button className="button" onClick={()=>void show(j.token)}>Status öffnen</button></p>)}</section>:null}<form onSubmit={prepare}><fieldset disabled={busy||!options}><Field label="Betreff"><input required maxLength={200} value={subject} onChange={e=>setSubject(e.target.value)}/></Field><Field label="Empfängerauswahl"><select value={mode} onChange={e=>setMode(e.target.value)}><option value="manual">Manuelle Mailadressen</option>{options?.bulk&&<><option value="customers">Kunden nach Typ und Branche</option><option value="groups">Mitglieder von Gruppen</option></>}</select></Field>{mode==='manual'?<><Field label="Mailadressen (An)"><textarea required rows={3} value={external} onChange={e=>setExternal(e.target.value)} placeholder="Mit Komma oder Semikolon trennen"/></Field><p className="form-hint">Vorlagen mit persönlichem Abmeldelink benötigen die Kunden- oder Gruppenauswahl.</p></>:<><h3>Niederlassungen der Empfänger</h3><div className="admin-toolbar">{options?.headquarters.map(h=><label className="admin-check" key={h.id}><input type="checkbox" checked={hqs.includes(h.id)} onChange={e=>setHqs(toggle(hqs,h.id,e.target.checked))}/>{h.label}</label>)}</div>{mode==='customers'?<><div className="admin-toolbar">{['Kunden','Interessenten','Potentialkunden','Wunschkunden'].map((name,i)=><label className="admin-check" key={i}><input type="checkbox" checked={types.includes(i)} onChange={e=>setTypes(toggle(types,i,e.target.checked))}/>{name}</label>)}</div><Field label="Branchen (leer = alle)"><select multiple size={7} value={branches.map(String)} onChange={e=>setBranches(Array.from(e.currentTarget.selectedOptions,o=>Number(o.value)))}>{options?.branches.map(b=><option value={b.id} key={b.id}>{b.label}</option>)}</select></Field></>:<Field label="Empfängergruppen"><select multiple size={8} value={groups.map(String)} onChange={e=>setGroups(Array.from(e.currentTarget.selectedOptions,o=>Number(o.value)))}>{options?.groups.map(g=><option key={g.id} value={g.id}>{g.label}</option>)}</select></Field>}<p className="form-hint">Kunden und Transporteure werden gemäß Newsletter-Freigabe und bestehender Einwilligungseinstellung ausgewählt.</p></>}<Field label="Kopie (Cc)"><input value={cc} onChange={e=>setCc(e.target.value)}/></Field><Field label="Blindkopie (Bcc)"><input value={bcc} onChange={e=>setBcc(e.target.value)}/></Field><label className="admin-check"><input type="checkbox" checked={copies} onChange={e=>setCopies(e.target.checked)}/>Cc und Bcc bei jeder generierten Mail einbeziehen</label><div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button className="button primary">Empfänger und Inhalt prüfen</button></div></fieldset></form></>}</div></Modal>{dialog}</>;
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { FileText, LoaderCircle, Plus, Printer } from 'lucide-react';
import { Field, Modal, useDiscardConfirmation } from './FormElements';
import { request, post, put } from './api';
import { EvaluationField } from './EvaluationField';
import { NewsletterMail } from './NewsletterMail';
type Hq={id:number;label:string};type Def={id:number;keyId:number;label:string;type:string;history:number};type Schema={fields:Def[];headquarters:number;revision:string};type Entry={id:number;headquarters:number;global:boolean;fields:{label:string;type:string;value:string}[]};type Detail={id:number;headquarters:number;values:Record<string,string>;revision:string;schema:Schema};
type Query={headquarters:number[];search:string;prefix:boolean;page:number};
export function Newsletters({onDirty}:{onDirty:(v:boolean)=>void}){return <MetafieldEntries onDirty={onDirty}/>;}
export function Evaluations({onDirty}:{onDirty:(v:boolean)=>void}){return <MetafieldEntries onDirty={onDirty} endpoint="/evaluations" singular="Bewertung" plural="Bewertungen"/>;}
function MetafieldEntries({onDirty,endpoint='/newsletters',singular='Newsletter',plural='Newsletter'}:{onDirty:(v:boolean)=>void;endpoint?:string;singular?:string;plural?:string}){
const [options,setOptions]=useState<{headquarters:Hq[];currentHeadquarters:number}|null>(null),[query,setQuery]=useState<Query|null>(null),[search,setSearch]=useState(''),[prefix,setPrefix]=useState(false),[selected,setSelected]=useState<number[]>([]),[data,setData]=useState<{items:Entry[];total:number}>({items:[],total:0}),[view,setView]=useState('list'),[editor,setEditor]=useState<Entry|'new'|null>(null),[preview,setPreview]=useState<Entry|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[loading,setLoading]=useState(false),[reload,setReload]=useState(0);
useEffect(()=>{let live=true;request<typeof options>(`${endpoint}/options`).then(o=>{if(live&&o){setOptions(o);setSelected([o.currentHeadquarters]);setQuery({headquarters:[o.currentHeadquarters],search:'',prefix:false,page:0});}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[endpoint]);
useEffect(()=>{if(!query)return;let live=true;setLoading(true);post<typeof data>(`${endpoint}/query`,query).then(d=>{if(live){setData(d);setError('');}}).catch(e=>{if(live)setError(e.message);}).finally(()=>{if(live)setLoading(false);});return()=>{live=false;};},[query,reload,endpoint]);
function apply(e:FormEvent){e.preventDefault();setQuery({headquarters:selected,search,prefix,page:0});}
return <><div className="admin-title"><div><h2>{plural}</h2><p>{plural} erfassen, bearbeiten und mit der hinterlegten Vorlage ausgeben.</p></div><button className="button primary" disabled={!options} onClick={()=>setEditor('new')}><Plus size={16}/>{singular==='Newsletter'?'Neuer Newsletter':'Neue Bewertung'}</button></div>{error&&<div role="alert" className="alert error">{error}</div>}{notice&&<p role="status" className="admin-success">{notice}</p>}<form onSubmit={apply}><div className="admin-toolbar">{options?.headquarters.map(h=><label className="admin-check" key={h.id}><input type="checkbox" checked={selected.includes(h.id)} onChange={e=>{const checked=e.target.checked;setSelected(v=>checked?[...v,h.id]:v.filter(id=>id!==h.id));}}/>{h.label}</label>)}</div><div className="admin-toolbar"><Field label="Suchbegriff"><input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></Field><Field label="Suchmodus"><select value={prefix?'prefix':'part'} onChange={e=>setPrefix(e.target.value==='prefix')}><option value="part">Teilwort</option><option value="prefix">Präfix</option></select></Field><button className="button primary" disabled={!selected.length||loading}>Suchen</button><Field label="Ansicht"><select value={view} onChange={e=>setView(e.target.value)}><option value="list">Liste</option><option value="cards">Karten</option></select></Field></div></form>{loading&&<LoaderCircle className="spin"/>}{view==='list'?<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Nr. / Niederlassung</th><th>Inhalt</th><th>Aktionen</th></tr></thead><tbody>{data.items.map(n=><tr key={`${n.headquarters}:${n.id}`}><td>#{n.id}<small>{n.global?'Global':options?.headquarters.find(h=>h.id===n.headquarters)?.label}</small></td><td className="message-text">{n.fields.filter(f=>f.value&&f.type!=='image').slice(0,endpoint==='/evaluations'?100:2).map(f=><div key={f.label}><strong>{f.label}</strong><p>{f.value.slice(0,400)}</p></div>)}</td><td><button className="button" onClick={()=>setEditor(n)}>Bearbeiten</button><button className="button" onClick={()=>setPreview(n)}>Vorschau / Ausgabe</button></td></tr>)}</tbody></table></div>:<div className="newsletter-cards">{data.items.map(n=><article className="panel" key={`${n.headquarters}:${n.id}`}><h3>{singular} #{n.id}</h3>{n.fields.filter(f=>f.value).slice(0,endpoint==='/evaluations'?100:4).map(f=><p className="message-text" key={f.label}><strong>{f.label}</strong><br/>{f.value.slice(0,300)}</p>)}<button className="button" onClick={()=>setEditor(n)}>Bearbeiten</button><button className="button" onClick={()=>setPreview(n)}>Vorschau / Ausgabe</button></article>)}</div>}{!loading&&!data.items.length&&<p>Keine {plural} für diese Filter.</p>}{query&&<div className="admin-toolbar"><span>{data.total} Einträge · Seite {query.page+1}</span><button className="button" disabled={loading||!query.page} onClick={()=>setQuery({...query,page:query.page-1})}>Zurück</button><button className="button" disabled={loading||(query.page+1)*50>=data.total} onClick={()=>setQuery({...query,page:query.page+1})}>Weiter</button></div>}
{editor&&options&&<NewsletterEditor endpoint={endpoint} singular={singular} headquartersOptions={options.headquarters} entry={editor} headquarters={options.currentHeadquarters} onDirty={onDirty} onClose={()=>setEditor(null)} onSaved={removed=>{setEditor(null);setReload(v=>v+1);setNotice(`${singular} ${removed?'gelöscht':'gespeichert'}.`);}}/>}{preview&&<NewsletterOutput endpoint={endpoint} singular={singular} onDirty={onDirty} entry={preview} onClose={()=>setPreview(null)}/>}</>;
}
function NewsletterEditor({endpoint,singular,headquartersOptions,entry,headquarters,onDirty,onClose,onSaved}:{endpoint:string;singular:string;headquartersOptions:Hq[];entry:Entry|'new';headquarters:number;onDirty:(v:boolean)=>void;onClose:()=>void;onSaved:(removed?:boolean)=>void}){
const hq=entry==='new'?headquarters:entry.headquarters;const [detail,setDetail]=useState<Detail|null>(null),[schema,setSchema]=useState<Schema|null>(null),[values,setValues]=useState<Record<string,string>>({}),[error,setError]=useState(''),[busy,setBusy]=useState(false),[removing,setRemoving]=useState(false),[history,setHistory]=useState<{fieldId:number;value:string;time:string}[]|null>(null);const [discard,dialog]=useDiscardConfirmation();
useEffect(()=>{let live=true;if(entry==='new')request<Schema>(`${endpoint}/schema?headquarters=${hq}`).then(s=>{if(live)setSchema(s);}).catch(e=>{if(live)setError(e.message);});else request<Detail>(`${endpoint}/${entry.id}?headquarters=${hq}`).then(d=>{if(live){setDetail(d);setSchema(d.schema);setValues(d.values);}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[entry,hq,endpoint]);
const dirty=JSON.stringify(values)!==JSON.stringify(detail?.values||{});useEffect(()=>{onDirty(dirty||busy);return()=>onDirty(false);},[dirty,busy,onDirty]);async function close(){if(!busy&&(!dirty||await discard(`Ungespeicherte Änderungen an ${singular} verwerfen?`)))onClose();}
async function save(e:FormEvent){e.preventDefault();if(!schema)return;setBusy(true);setError('');try{const s={headquarters:hq,revision:detail?.revision||null,schemaRevision:schema.revision,values};if(entry==='new')await post(endpoint,s);else await put(`${endpoint}/${entry.id}`,s);onSaved();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function remove(){if(!detail)return;setBusy(true);try{await request(`${endpoint}/${detail.id}?headquarters=${hq}`,{method:'DELETE',body:JSON.stringify({revision:detail.revision})});onSaved(true);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function upload(field:number,file:File|undefined){if(!file)return;if(file.size>300000){setError('Bilder dürfen höchstens 300 KB groß sein.');return;}setBusy(true);setError('');try{const data=await new Promise<string>((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(String(r.result));r.onerror=()=>reject(new Error('Bild konnte nicht gelesen werden.'));r.readAsDataURL(file);});const result=await post<{filename:string}>(`${endpoint}/images`,{data});setValues(v=>({...v,[field]:result.filename}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const set=(id:number,v:string)=>setValues(old=>({...old,[id]:v}));
return <><Modal title={entry==='new'?`${singular} anlegen`:`${singular} #${entry.id}`} onClose={close} wide><form className="admin-dialog-body" onSubmit={save}>{error&&<div role="alert" className="alert error">{error}</div>}{!schema&&!error&&<LoaderCircle className="spin"/>}{schema&&<><p>Geltungsbereich: {schema.headquarters?'Niederlassung des Formulars':'Global'}</p><fieldset disabled={busy}>{schema.fields.map(f=>endpoint==='/evaluations'?<EvaluationField key={f.id} field={f} value={values[f.id]||''} headquarters={hq} headquartersOptions={headquartersOptions} onChange={v=>set(f.id,v)}/>:<Field label={f.label} key={f.id}>{f.type==='text'?<textarea rows={4} value={values[f.id]||''} onChange={e=>set(f.id,e.target.value)}/>:f.type==='checkbox'?<input type="checkbox" checked={values[f.id]==='1'} onChange={e=>set(f.id,e.target.checked?'1':'')}/>:<input value={values[f.id]||''} readOnly={!['varchar','image','link','url_business'].includes(f.type)} placeholder={f.type==='image'?'Bildname.jpg,Breite,Höhe,Titel':undefined} onChange={e=>set(f.id,e.target.value)}/>}</Field>)}{schema.fields.filter(f=>f.type==='image').map(f=><Field key={f.id} label={`Bild hochladen: ${f.label}`}><input type="file" accept="image/png,image/jpeg,image/gif" onChange={e=>void upload(f.id,e.target.files?.[0])}/></Field>)}{schema.fields.some(f=>f.type==='image')&&<p className="form-hint">Bilder bis 300 KB. Ein Upload ersetzt die Bildauswahl im Entwurf; zum Übernehmen das Formular speichern.</p>}<div className="admin-row-actions"><button type="button" className="button" onClick={close}>Abbrechen</button><button type="button" className="button" disabled={!dirty} onClick={async()=>{if(await discard('Eingaben auf den zuletzt geladenen Stand zurücksetzen?'))setValues(detail?.values||{});}}>Zurücksetzen</button>{detail&&<><button type="button" className="button" onClick={()=>{request<typeof history>(`${endpoint}/${detail.id}/history?headquarters=${hq}`).then(setHistory).catch(e=>setError(e.message));}}>Feldhistorie</button><button type="button" className="button danger" onClick={()=>setRemoving(true)}>Löschen</button></>}<button className="button primary" disabled={!dirty}>Speichern</button></div></fieldset></>}{history&&<section><h3>Feldhistorie</h3>{!history.length?<p>Keine historischen Werte vorhanden.</p>:history.map((h,i)=><p className="message-text" key={i}><strong>{schema?.fields.find(f=>f.id===h.fieldId)?.label} · {new Date(h.time).toLocaleString('de-DE')}</strong><br/>{h.value}</p>)}</section>}</form></Modal>{removing&&<Modal title={`${singular} löschen`} onClose={()=>{if(!busy)setRemoving(false);}}><div className="admin-dialog-body"><p>{singular} #{detail?.id} mit seinen Werten dauerhaft löschen?</p><button className="button" disabled={busy} onClick={()=>setRemoving(false)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={()=>void remove()}>{singular} löschen</button></div></Modal>}{dialog}</>;
}
function NewsletterOutput({endpoint,singular,entry,onClose,onDirty}:{endpoint:string;singular:string;entry:Entry;onClose:()=>void;onDirty:(v:boolean)=>void}){
const [mail,setMail]=useState(false);
const [html,setHtml]=useState(''),[error,setError]=useState('');const frame=useRef<HTMLIFrameElement>(null);
useEffect(()=>{let live=true;request<{html:string}>(`${endpoint}/${entry.id}/preview?headquarters=${entry.headquarters}`).then(d=>{if(live)setHtml(d.html);}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[entry,endpoint]);
const documentHtml=`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><title>${singular} ${entry.id}</title></head><body>${html}</body></html>`;
return <><Modal title={`Vorschau: ${singular} #${entry.id}`} onClose={onClose} wide><div className="admin-dialog-body">{error&&<div role="alert" className="alert error">{error}</div>}{html?<><div className="admin-toolbar"><button className="button" onClick={()=>setMail(true)}>E-Mail vorbereiten</button><button className="button primary" onClick={()=>frame.current?.contentWindow?.print()}><Printer size={16}/>Drucken / als PDF speichern</button><button className="button" onClick={()=>{const url=URL.createObjectURL(new Blob([documentHtml],{type:'text/html;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=`${singular}-${entry.id}.html`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}}><FileText size={16}/>HTML herunterladen</button></div><iframe ref={frame} title={`${singular}-Vorschau`} className="newsletter-preview" sandbox="allow-same-origin allow-modals" srcDoc={documentHtml}/></>:!error&&<LoaderCircle className="spin"/>}</div></Modal>{mail&&<NewsletterMail endpoint={endpoint} singular={singular} id={entry.id} headquarters={entry.headquarters} onDirty={onDirty} onClose={()=>setMail(false)}/>}</>;
}
+45
View File
@@ -0,0 +1,45 @@
import {useEffect,useRef,useState} from 'react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {Lookup} from './EntryLookup';
import {ErrorMessage,uncertainWrite} from './BusinessForms';
import {request,post,put} from './api';
type Position={id:number;stationId:number;articleId:number;name:string;quantity:number;weight:number;packingPieces:number;price:number;serialNumber:string;remark:string};
type Article={id:number;number:string;name:string;weight:number;packingPieces:number;serialRequired:boolean};
type Stock={id:number;label:string;available:number};
type Movement={id:number;at:string;articleId:number;quantity:number;from:number;to:number;serial:string;remark:string;bundleId?:number;transaction?:string;specialDate?:string;data?:string;reversed:boolean};
type Detail={revision:string;payerId:number;canBook:boolean;positions:Position[];protectedIds:number[];stations:{id:number;label:string;completed:boolean}[];movements:Movement[]};
type Preview={plan?:{reverseId:number;bundleId:number;transaction:string;specialDate:string|null;data:string[]}[];fingerprint:string;item:{articleId:number;quantity:number;serialNumber:string;booking:{fromStockId:number;toStockId:number}};lines:{name:string;quantity:number;source:string;destination:string}[]};
const outside:Stock={id:0,label:'Außerhalb des Lagers',available:0};
export function OrderArticles({id,onClose,onSaved}:{id:number;onClose:()=>void;onSaved:()=>void}){
const [data,setData]=useState<Detail|null>(null),[rows,setRows]=useState<Position[]>([]),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
const [article,setArticle]=useState<Article|null>(null),[from,setFrom]=useState<Stock|null>(null),[to,setTo]=useState<Stock|null>(null),[quantity,setQuantity]=useState(1),[serial,setSerial]=useState(''),[reason,setReason]=useState(''),[reverse,setReverse]=useState<Movement|null>(null),[preview,setPreview]=useState<Preview|null>(null);
const [transaction,setTransaction]=useState(''),[specialDate,setSpecialDate]=useState(''),[extraData,setExtraData]=useState<string[]>([]);
const [discard,dialog]=useDiscardConfirmation(),pending=useRef<{path:string;body:unknown;method:'put'|'post'}|null>(null);
const dirty=!!data&&JSON.stringify(rows)!==JSON.stringify(data.positions),movementDirty=!!(article||reverse||reason||transaction||specialDate||extraData.length);
const base=`/orders/${id}/articles`;
function receive(d:Detail){setData(d);setRows(d.positions);}
useEffect(()=>{request<Detail>(base).then(receive).catch(e=>setError(e.message));},[base]);
async function close(){if(!busy&&!uncertain&&(!(dirty||movementDirty)||await discard('Ungespeicherte Artikel- oder Lageränderungen verwerfen?')))onClose();}
function change(index:number,patch:Partial<Position>){setRows(old=>old.map((r,i)=>i===index?{...r,...patch}:r));setNotice('');}
function movementChanged(){setPreview(null);setNotice('');}
async function save(kind:'positions'|'movement'){
if(!data)return;setBusy(true);setError('');
pending.current??=kind==='positions'?{path:base,method:'put',body:{requestId:crypto.randomUUID(),revision:data.revision,positions:rows}}:{path:base+'/movement',method:'post',body:{...movementInput(),requestId:crypto.randomUUID(),fingerprint:preview?.fingerprint}};
try{const p=pending.current;receive(await (p.method==='put'?put<Detail>(p.path,p.body):post<Detail>(p.path,p.body)));pending.current=null;setUncertain(false);setPreview(null);setArticle(null);setFrom(null);setTo(null);setReason('');setReverse(null);setTransaction('');setSpecialDate('');setExtraData([]);setNotice(kind==='positions'?'Artikelpositionen gespeichert.':'Lagerbewegung gebucht; Journal aktualisiert.');onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?' Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.':''));}finally{setBusy(false);}
}
function movementInput(){return {revision:data?.revision,reverseId:reverse?.id||0,reason,extra:{transaction:transaction||null,specialDate:specialDate||null,data:extraData},item:reverse?null:{articleId:article?.id,station:1,quantity,serialNumber:serial,remark:'',booking:from&&to?{fromStockId:from.id,toStockId:to.id}:null}};}
async function calculate(){setBusy(true);setError('');try{setPreview(await post<Preview>(base+'/movement/preview',movementInput()));}catch(e){setPreview(null);setError((e as Error).message);}finally{setBusy(false);}}
return <><Modal title={`Artikel und Lager · Auftrag ${id}`} onClose={()=>void close()} wide><div className="modal-body"><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}{data?<>
<section><h3>Artikelpositionen</h3><p>Artikelangaben und Lagerjournal werden getrennt geführt. Eine Änderung dieser Liste bucht keinen Lagerbestand. Gescannte oder bereits zugestellte Positionen bleiben als Nachweis erhalten.</p>
<fieldset className="business-fieldset" disabled={busy||uncertain||movementDirty}>{rows.map((r,i)=><fieldset className="business-station" key={r.id||`new-${i}`} disabled={data.protectedIds.includes(r.id)}><legend>Position {i+1}{data.protectedIds.includes(r.id)?' · Nachweis geschützt':''}</legend><Lookup<Article> label={`Stammartikel ${i+1} (optional)`} endpoint="/orders/entry/articles" value={r.articleId?{id:r.articleId,number:String(r.articleId),name:r.name,weight:r.weight,packingPieces:r.packingPieces,serialRequired:false}:null} describe={a=>`${a.number} · ${a.name}`} disabled={busy||uncertain||data.protectedIds.includes(r.id)||movementDirty} onSelect={a=>{if(a)change(i,{articleId:a.id,name:a.name.slice(0,30),weight:a.weight*r.quantity,packingPieces:a.packingPieces,serialNumber:''});else change(i,{articleId:0});}}/>
<div className="form-grid cols-3"><Field label={`Station ${i+1}`}><select value={r.stationId} onChange={e=>change(i,{stationId:Number(e.target.value)})}>{data.stations.map(s=><option key={s.id} value={s.id} disabled={s.completed&&s.id!==r.stationId}>{s.label}</option>)}</select></Field><Field label={`Bezeichnung ${i+1}`}><input maxLength={30} value={r.name} onChange={e=>change(i,{name:e.target.value})}/></Field><Field label={`Stückzahl ${i+1}`}><input type="number" min={1} max={32767} value={r.quantity} onChange={e=>change(i,{quantity:Number(e.target.value)})}/></Field><Field label={`Gesamtgewicht ${i+1} (kg)`}><input type="number" min={0} max={999999.999} step="0.001" value={r.weight} onChange={e=>change(i,{weight:Number(e.target.value)})}/></Field><Field label={`Packstücke ${i+1}`}><input type="number" min={0} max={127} value={r.packingPieces} onChange={e=>change(i,{packingPieces:Number(e.target.value)})}/></Field><Field label={`Artikelwert ${i+1} (€)`}><input type="number" min={0} max={999999.99} step="0.01" value={r.price} onChange={e=>change(i,{price:Number(e.target.value)})}/></Field><Field label={`Seriennummer ${i+1}`}><input maxLength={50} value={r.serialNumber} onChange={e=>change(i,{serialNumber:e.target.value})}/></Field><Field label={`Artikelhinweis ${i+1}`} wide><input maxLength={255} value={r.remark} onChange={e=>change(i,{remark:e.target.value})}/></Field></div><button className="button" type="button" onClick={()=>setRows(v=>v.filter((_,n)=>n!==i))}>Position entfernen</button></fieldset>)}
<div className="admin-row-actions"><button className="button" disabled={rows.length>=100||!data.stations.some(s=>!s.completed)} onClick={()=>setRows(v=>[...v,{id:0,stationId:data.stations.find(s=>!s.completed)!.id,articleId:0,name:'',quantity:1,weight:0,packingPieces:0,price:0,serialNumber:'',remark:''}])}>Artikelposition hinzufügen</button><button className="button primary" disabled={!dirty} onClick={()=>void save('positions')}>Artikelpositionen speichern</button><button className="button" disabled={!dirty} onClick={async()=>{if(await discard('Artikeländerungen verwerfen?'))setRows(data.positions);}}>Änderungen verwerfen</button></div></fieldset></section>
{data.canBook&&<section className="business-section"><h3>Lagerjournal</h3><p>0 bezeichnet den Ein- oder Ausgang außerhalb des Lagers. Korrekturen werden als zusätzliche Bewegungen protokolliert; bestehende Journalzeilen bleiben erhalten.</p><div className="table-scroll"><table className="admin-table"><thead><tr><th>Nr. / Datum</th><th>Artikel / Menge</th><th>Von → Nach</th><th>Seriennummer / Hinweis</th><th>Korrektur</th></tr></thead><tbody>{data.movements.map(m=><tr key={m.id}><td>#{m.id}<br/>{m.at}</td><td>#{m.articleId} · {m.quantity}</td><td>{m.from} → {m.to}</td><td>{m.serial}<br/>{m.remark}{!!m.bundleId&&<p>Bündel #{m.bundleId}</p>}{m.transaction&&<p>TAN: {m.transaction}</p>}{m.specialDate&&<p>Zusatzdatum: {m.specialDate}</p>}{m.data&&<p>{m.data.split(/<br\s*\/?>/i).map((v,i)=>v?`${i+1}: ${v}`:"").filter(Boolean).join(" · ")}</p>}</td><td>{m.reversed?'Gegengebucht':<button className="button" disabled={busy||uncertain||dirty} onClick={()=>{setReverse(m);setArticle(null);setReason('');setTransaction('');setSpecialDate('');setExtraData([]);movementChanged();}}>Gegenbuchung vorbereiten</button>}</td></tr>)}</tbody></table></div>
<h3>{reverse?`Gegenbuchung zu Bewegung #${reverse.id}`:'Weitere Lagerbewegung'}</h3><fieldset className="business-fieldset" disabled={busy||uncertain||dirty}>{reverse?<p>Artikel #{reverse.articleId}, Menge {reverse.quantity}: Lager {reverse.to} → {reverse.from}. {reverse.bundleId?`Das gesamte Bündel #${reverse.bundleId} wird gemeinsam gegengebucht.`:"Der verfügbare Bestand wird vor dem Buchen geprüft."}</p>:<><Lookup<Article> label="Artikel für Lagerbewegung" endpoint="/orders/entry/articles" value={article} describe={a=>`${a.number} · ${a.name}`} disabled={busy||uncertain||dirty} onSelect={a=>{setArticle(a);setFrom(null);setTo(null);setSerial('');movementChanged();}}/><div className="form-grid"><Field label="Buchungsmenge"><input type="number" min={1} max={32767} value={quantity} onChange={e=>{setQuantity(Number(e.target.value));movementChanged();}}/></Field><Field label="Seriennummer der Lagerbewegung"><input maxLength={50} value={serial} onChange={e=>{setSerial(e.target.value);movementChanged();}}/></Field></div>{article&&<div className="form-grid">{(['from','to'] as const).map(side=><div key={side}><Lookup<Stock> label={side==='from'?'Quelllager':'Ziellager'} endpoint={`/orders/entry/stocks?payerId=${data.payerId}&articleId=${article.id}`} value={side==='from'?from:to} describe={s=>`${s.label} · Bestand ${s.available}`} disabled={busy||uncertain||dirty} onSelect={s=>{(side==='from'?setFrom:setTo)(s);movementChanged();}}/><button className="button" onClick={()=>{(side==='from'?setFrom:setTo)(outside);movementChanged();}}>{side==='from'?'Eingang von außerhalb':'Ausgang nach außerhalb'}</button></div>)}</div>}</>}
{!reverse&&<div className="form-grid"><Field label="Transaktionsnummer (TAN)"><input maxLength={20} value={transaction} placeholder={String(id)} onChange={e=>{setTransaction(e.target.value);movementChanged();}}/></Field><Field label="Zusatzdatum"><input type="date" value={specialDate} onChange={e=>{setSpecialDate(e.target.value);movementChanged();}}/></Field></div>}
<details><summary>Zusatzdaten des Lagerartikels</summary><p>{reverse?'Gesicherte Zusatzdaten werden automatisch wiederhergestellt. Für alte Seriennummern-Auslagerungen ohne gespeicherte Daten hier die vollständigen Angaben ergänzen.':'Die Zusatzdaten werden im Journal und bei Seriennummern-Einlagerungen am Artikel gespeichert.'}</p><div className="form-grid cols-3">{Array.from({length:25},(_,i)=><Field key={i} label={`Zusatzfeld ${i+1}`}><input maxLength={50} value={extraData[i]||''} onChange={e=>{setExtraData(old=>Array.from({length:25},(_,n)=>n===i?e.target.value:old[n]||''));movementChanged();}}/></Field>)}</div></details>
<Field label="Begründung der Lagerbuchung"><input maxLength={180} value={reason} onChange={e=>{setReason(e.target.value);movementChanged();}}/></Field><div className="admin-row-actions"><button className="button" disabled={!reason.trim()||!reverse&&(!article||!from||!to)} onClick={()=>void calculate()}>Lagerbuchung prüfen</button><button className="button" disabled={!movementDirty} onClick={()=>{setReverse(null);setArticle(null);setReason('');setTransaction('');setSpecialDate('');setExtraData([]);setPreview(null);}}>Buchungsentwurf verwerfen</button></div></fieldset>
{preview&&<div className="business-section"><h4>Buchungsvorschau</h4>{preview.lines.map((l,i)=><p key={i}>{l.quantity} × {l.name}: {l.source} → {l.destination}</p>)}{preview.plan?.map((p,i)=><p key={`meta-${i}`}>{p.reverseId?`Gegenbuchung #${p.reverseId} · `:""}{p.bundleId?`Bündel #${p.bundleId} · `:""}TAN {p.transaction||id}{p.specialDate?` · Zusatzdatum ${p.specialDate.slice(0,10)}`:""}{p.data.some(Boolean)?` · Zusatzdaten: ${p.data.map((v,n)=>v?`${n+1}: ${v}`:"").filter(Boolean).join("; ")}`:""}</p>)}<button className="button primary" disabled={busy||dirty} onClick={()=>void save('movement')}>Lagerbewegung verbindlich buchen</button></div>}</section>}
{uncertain&&<button className="button primary" disabled={busy} onClick={()=>void save(pending.current?.method==='put'?'positions':'movement')}>Speichervorgang erneut prüfen</button>}</>:!error&&<p>Artikel werden geladen …</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button></div></Modal>{dialog}</>;
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef, useState } from "react";
export type OrderAttachment = { filename: string; viewUrl: string; remark: string };
export function OrderAttachments({ url, files, onChange, disabled, station, onOpenChange }: { url: string; files: OrderAttachment[]; onChange: (files: OrderAttachment[]) => void; disabled: boolean; station: number; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false), [error, setError] = useState("");
const openChange = useRef(onOpenChange);
useEffect(() => { openChange.current = onOpenChange; }, [onOpenChange]);
useEffect(() => () => openChange.current(false), []);
const frame = useRef<HTMLIFrameElement>(null), session = useRef(crypto.randomUUID());
useEffect(() => {
if (!url || !open) return;
const origin = new URL(url).origin;
const receive = (event: MessageEvent) => {
if (event.origin !== origin || event.source !== frame.current?.contentWindow) return;
const data = event.data;
if (!data || !["upload-success", "delete-success"].includes(data.type)) return;
if (String(data.sessionId) !== session.current || typeof data.file?.filename !== "string") return;
if (data.type === "delete-success") { onChange(files.filter(f => f.filename !== data.file.filename)); return; }
try {
if (typeof data.file.viewUrl !== "string" || new URL(data.file.viewUrl).origin !== origin || data.file.filename.length > 255 || data.file.viewUrl.length > 1500 || files.length >= 30) throw new Error();
onChange([...files.filter(f => f.filename !== data.file.filename), { filename: data.file.filename, viewUrl: data.file.viewUrl, remark: String(data.file.remark || "").slice(0, 200) }]);
setError("");
} catch { setError("Der Upload wurde nicht zugeordnet. Bitte Dateilink, Dateiname und die Grenze von 30 Dateien prüfen."); }
};
window.addEventListener("message", receive);
return () => window.removeEventListener("message", receive);
}, [url, open, files, onChange]);
return <div className="entry-attachments">
{url && <button type="button" className="text-button" disabled={disabled} onClick={() => { setOpen(!open); onOpenChange(!open); }}>{open ? "Dateiverwaltung schließen" : `Dateien für Station ${station} hochladen`}</button>}
{open && <iframe ref={frame} title={`Dateiverwaltung Station ${station}`} className="entry-upload-frame" src={`${url.replace(/\/$/, "")}/upload?ref=${encodeURIComponent(session.current)}&readonly=${disabled}`} allow="storage-access" />}
{files.length > 0 && <ul>{files.map(file => <li key={file.filename}><a href={file.viewUrl} target="_blank" rel="noreferrer">{file.filename}</a>{file.remark && ` · ${file.remark}`}</li>)}</ul>}
{error && <p className="alert error" role="alert">{error}</p>}
{open && <p className="form-hint">Die Dateien werden beim Speichern mit dieser Station verknüpft.</p>}
</div>;
}
+28
View File
@@ -0,0 +1,28 @@
import {useEffect,useRef,useState} from 'react';
import {Field,Modal,useDiscardConfirmation} from './FormElements';
import {ErrorMessage,uncertainWrite} from './BusinessForms';
import {request,post} from './api';
type Input={action:string;reason:string;chargeable:boolean;endSeries:boolean;offsetPayments:boolean};
type Row={id:number;kind:string;courier:string;net:number;creditNet:number;creditCourierNet:number;creditToll:number;payments:{id:number;amount:number;offset:number;exported:boolean}[]};
type Detail={revision:string;canCancel:boolean;canCredit:boolean;canOffsetPayments:boolean;hasPayments:boolean;series:boolean;documents:Row[]};
type Preview={fingerprint:string;documents:Row[];fee:number;courierFee:number};
const euro=(v:number)=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(v);
export function OrderCancellation({id,onClose,onSaved}:{id:number;onClose:()=>void;onSaved:()=>void}){
const [data,setData]=useState<Detail|null>(null),[input,setInput]=useState<Input>({action:'CANCEL',reason:'',chargeable:false,endSeries:false,offsetPayments:false}),[preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
const pending=useRef<object|null>(null),[discard,dialog]=useDiscardConfirmation(),base=`/orders/${id}/cancellation`;
useEffect(()=>{request<Detail>(base).then(d=>{setData(d);setInput(v=>({...v,action:d.canCancel?'CANCEL':'CREDIT'}));}).catch(e=>setError(e.message));},[base]);
function change(patch:Partial<Input>){setInput(v=>({...v,...patch}));setPreview(null);pending.current=null;}
async function close(){if(!busy&&!uncertain&&(!input.reason||notice||await discard('Stornoentwurf verwerfen?')))onClose();}
async function calculate(){if(!data)return;setBusy(true);setError('');try{setPreview(await post<Preview>(base+'/preview',{revision:data.revision,input}));}catch(e){setPreview(null);setError((e as Error).message);}finally{setBusy(false);}}
async function save(){if(!data||!preview)return;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),revision:data.revision,input,fingerprint:preview.fingerprint};try{const r=await post<{resultId:number}>(base,pending.current);pending.current=null;setUncertain(false);setNotice(`Gemeinsame Korrektur gespeichert. Gegenbuchung zum Hauptauftrag: #${r.resultId}.`);setPreview(null);onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?' Bitte denselben Vorgang erneut prüfen.':''));}finally{setBusy(false);}}
return <><Modal title={`Storno / Gutschrift · Auftrag ${id}`} onClose={()=>void close()} wide><div className="modal-body"><ErrorMessage message={error}/>{notice?<p role="status" className="admin-success">{notice}</p>:data?<>
<p>Hauptauftrag und verbundene Unternehmerbelege werden gemeinsam korrigiert. Die Originalbelege und Zustellnachweise bleiben erhalten.</p>
<fieldset className="business-fieldset" disabled={busy||uncertain}><div className="form-grid"><Field label="Art der Korrektur"><select value={input.action} onChange={e=>change({action:e.target.value})}><option value="CANCEL" disabled={!data.canCancel}>Storno mit Gegenbuchung</option><option value="CREDIT" disabled={!data.canCredit}>Gutschrift / Gegenbuchung</option></select></Field><Field label="Begründung für Storno / Gutschrift"><textarea maxLength={500} value={input.reason} onChange={e=>change({reason:e.target.value})}/></Field></div>
<label className="checkbox-label"><input type="checkbox" checked={input.chargeable} onChange={e=>change({chargeable:e.target.checked})}/>Stornogebühr gemäß Tarif des Hauptauftrags</label>{data.series&&<label className="checkbox-label"><input type="checkbox" checked={input.endSeries} onChange={e=>change({endSeries:e.target.checked})}/>Dauerauftragsrhythmus beenden</label>}
{data.hasPayments&&<><p>Vorhandene Einnahmen werden durch zusätzliche Buchungen mit umgekehrtem Betrag ausgeglichen. Bereits exportierte Einnahmen bleiben erhalten. Eine Auszahlung oder Rücküberweisung wird damit nicht ausgeführt.</p><label className="checkbox-label"><input type="checkbox" disabled={!data.canOffsetPayments} checked={input.offsetPayments} onChange={e=>change({offsetPayments:e.target.checked})}/>Einnahmen mit Gegenbuchungen ausgleichen</label>{!data.canOffsetPayments&&<p className="alert info">Für den Einnahmenausgleich fehlt die Berechtigung.</p>}</>}
<button className="button" disabled={!input.reason.trim()||data.hasPayments&&!input.offsetPayments||!data.canCancel&&!data.canCredit} onClick={()=>void calculate()}>Stornovorschau berechnen</button></fieldset>
<section className="business-section"><h3>{preview?'Verbindliche Vorschau':'Betroffene Belege'}</h3><div className="table-scroll"><table className="admin-table"><thead><tr><th>Beleg</th><th>Netto bisher</th>{preview&&<><th>Gegenbuchung netto</th><th>Frachtvergütung</th><th>Maut</th></>}</tr></thead><tbody>{(preview?.documents||data.documents).map(d=><tr key={d.id}><td>#{d.id} · {d.kind}<br/>{d.courier}</td><td>{euro(d.net)}</td>{preview&&<><td>{euro(d.creditNet)}</td><td>{euro(d.creditCourierNet)}</td><td>{euro(d.creditToll)}</td></>}</tr>)}</tbody></table></div>
{(preview?.documents||data.documents).flatMap(d=>d.payments.map(p=><p key={p.id}>Einnahme #{p.id} zu Beleg #{d.id}: {euro(p.amount)}{p.exported?' · bereits exportiert':''}{preview?` → zusätzliche Buchung ${euro(p.offset)}`:''}</p>))}
{preview&&<><p>Enthaltene Stornogebühr: Kunde {euro(preview.fee)} · Unternehmer {euro(preview.courierFee)}.</p><button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Alle angezeigten Belege verbindlich korrigieren'}</button></>}</section>
</>:!error&&<p>Belege werden geladen …</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button></div></Modal>{dialog}</>;
}
+420
View File
@@ -0,0 +1,420 @@
import { type PhotoOptions, type PhotoSet, photoFlows, defaultPhoto, photoLabel } from "./PhotoSets";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { ArrowDown, ArrowUp, Check, LoaderCircle, Plus, X } from "lucide-react";
import { ApiError, request as apiRequest } from "./api";
import { useOrderEntryShortcuts } from "./useOrderEntryShortcuts";
import { EntryScope, useEntryApi } from "./EntryScope";
import { Field, Modal } from "./FormElements";
import { EntryArticleFields, EntryCourierFields, type ArticleDraft, type CourierDraft } from "./OrderFeatureFields";
import { Lookup } from "./EntryLookup";
import { OrderMapPicker } from "./OrderMapPicker";
import { OrderAttachments, type OrderAttachment } from "./OrderAttachments";
type Mode = "IMMEDIATE" | "RESERVATION" | "RECURRING" | "RETROSPECTIVE";
type Option = { id: number; label: string };
type CostCenter = { id: number; customerId: number; customerNumber: string; customerName: string; name: string; defaultPayerId: number | null; fixedDiscount: number; taxId: number | null };
type Vehicle = { id: number; courierId: number; sid: string; name: string; vehicleTypeId: number; plate: string };
type Station = { costCenterId: number | null; type: "PICKUP" | "DELIVERY" | "BOTH"; name: string; street: string; houseNumber: string; postalCode: string; city: string; country: string; contact: string; phone: string; note: string; reference: string; photoWorkflow: number | null; billingPostalId: number | null; attachments: OrderAttachment[] };
type Address = Omit<Station, "type" | "reference"> & { id: number };
type EditorStation = Station & { key: string };
type Options = { vehicleTypes: Option[]; taxes: (Option & { rate: number })[]; rhythms: Option[]; editCourierPrice: boolean; overrideCourierLimit: boolean; euCountries?: string[] };
type RouteInfo = { distanceKm: number; calculationKm: number; durationSeconds: number; dispatchHeadquarters: string; pricingHeadquartersId: number; longhaulEligible: boolean; longhaul: boolean; comparedHeadquarters: { id: number; name: string; roundTripKm: number }[] };
type CustomerDefaults = { invoiceEmail: string; vatId: string; referenceRequired: boolean; calculationRequired: boolean };
type ServiceOption = { serviceId: number; typeId: number; service: string; type: string };
type ServiceItem = { serviceId: number; typeId: number; quantity: number };
type SavedOrder = { id: number; name: string; customer: string; pickupAt: string };
type Quote = { photoSet?: PhotoSet | null; sustainability?: { distanceKm: number; co2eKg: number; source: string } | null; costAllocations?: { costCenterId: number; name: string; net: number; toll: number; tax: number; gross: number }[]; courierAllocations?: { vehicleId: number; sid: string; net: number; base: number }[]; articleLines?: { articleId: number; station: number; name: string; quantity: number; source: string; destination: string; booked: boolean }[]; schedule?: { pickupAt: string; count: number; net: number; gross: number }[]; toll?: number; insurancePremium?: number; copies?: number; route?: RouteInfo; photos?: { station: number; number: number; name: string }[]; calculation?: { name: string; quantity: number; rate: number; amount: number }[]; net: number; tax: number; gross: number; courierNet: number; markup: number; courierMarkup: number; discount: number; taxRate: number; batch: boolean; fingerprint: string; courierLimit: number; courierLimitExceeded: boolean; components: { sequence: number; name: string; price: number; discount: number; net: number }[] };
type Created = { id: number; number: string; legacyStatus: number; replayed: boolean; pickupAt: string; photoSyncPending?: boolean; orderIds?: number[] };
const euro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value);
const costCenterLabel = (value: CostCenter) => `${value.customerNumber} · ${value.customerName} · ${value.name}`;
const vehicleLabel = (value: Vehicle) => `${value.sid} · ${value.name}${value.plate ? ` · ${value.plate}` : ""}`;
const newStop = (type: Station["type"]): EditorStation => ({ key: crypto.randomUUID(), costCenterId: null, type, name: "", street: "", houseNumber: "", postalCode: "", city: "", country: "DE", contact: "", phone: "", note: "", reference: "", photoWorkflow: null, billingPostalId: null, attachments: [] });
function StopEditor({ stop, index, count, update, move, remove, disabled, photos, uploadUrl, mapUrl, onMapOpenChange, onUploadOpenChange }: {
stop: EditorStation; index: number; count: number; update: (patch: Partial<EditorStation>) => void; move: (delta: number) => void; remove: () => void; disabled: boolean; photos: PhotoSet | null; uploadUrl: string; mapUrl: string; onMapOpenChange: (open: boolean) => void; onUploadOpenChange: (open: boolean) => void;
}) {
const [billing, setBilling] = useState<Option | null>(null);
const { request } = useEntryApi();
const [center, setCenter] = useState<CostCenter | null>(null), [useAddress, setUseAddress] = useState(false), [addresses, setAddresses] = useState<Address[]>([]), [addressError, setAddressError] = useState("");
useEffect(() => {
let active = true;
setAddresses([]); setAddressError("");
if (!center) return;
request<Address[]>(`/orders/entry/cost-centers/${center.id}/addresses`).then(values => { if (active) setAddresses(values); })
.catch((e: Error) => { if (active) setAddressError(e.message); });
return () => { active = false; };
}, [center]);
return <section data-station-type={stop.type} data-station-key={stop.key} className="stop-editor" aria-label={`Station ${index + 1}`}>
<div className="stop-editor-heading"><span className={`stop-number ${stop.type === "PICKUP" ? "pickup" : ""}`}>{index + 1}</span>
<select aria-label={`Art der Station ${index + 1}`} value={stop.type} onChange={e => update({ type: e.target.value as Station["type"] })} disabled={disabled}>
<option value="PICKUP">Abholung</option><option value="DELIVERY">Zustellung</option><option value="BOTH">Abholung und Zustellung</option>
</select><div className="stop-controls">
<button type="button" className="icon-button" aria-label="Station nach vorne" disabled={disabled || index === 0} onClick={() => move(-1)}><ArrowUp size={16} /></button>
<button type="button" className="icon-button" aria-label="Station nach hinten" disabled={disabled || index === count - 1} onClick={() => move(1)}><ArrowDown size={16} /></button>
<button type="button" className="icon-button" aria-label="Station entfernen" disabled={disabled || count <= 1} onClick={remove}><X size={16} /></button>
</div></div>
<button type="button" data-entry-key="company" className="text-button" disabled={disabled} onClick={() => setUseAddress(!useAddress)}>{useAddress ? "Adresssuche schließen" : "Adresse aus Kostenstelle übernehmen"}</button>
{useAddress && <div className="entry-address-search">
<Lookup label="Stationskostenstelle" endpoint="/orders/entry/cost-centers" value={center} describe={costCenterLabel} disabled={disabled} onSelect={value => { setCenter(value); update({ costCenterId: value?.id || null }); }} />
{addressError && <p role="alert" className="alert error">{addressError}</p>}
{center && <Field label="Gespeicherte Adresse"><select value="" disabled={disabled} onChange={e => { const address = addresses.find(a => a.id === Number(e.target.value)); if (address) { const { id: _id, ...values } = address; update({ ...values, country: values.country || "DE", costCenterId: center.id }); } }}>
<option value="">{addresses.length ? "Adresse übernehmen …" : "Keine gespeicherte Adresse"}</option>{addresses.map(a => <option key={a.id} value={a.id}>{a.name} · {a.street} {a.houseNumber} · {a.postalCode} {a.city}</option>)}
</select></Field>}
</div>}
<details><summary>Adresse suchen und Rechnungs-PLZ</summary>
<Lookup<Address> shortcut="address" label="Straße oder PLZ aus bisherigen Aufträgen" endpoint="/orders/entry/addresses" value={null} describe={a => `${a.street} · ${a.postalCode} ${a.city}`} disabled={disabled} onSelect={a => { if (a) update({ street: a.street, postalCode: a.postalCode, city: a.city, country: a.country, billingPostalId: null }); }} />
<Lookup<Option> label="Abweichende Rechnungs-PLZ" endpoint="/orders/entry/billing-postal-codes" value={billing ?? (stop.billingPostalId ? { id: stop.billingPostalId, label: `Hinterlegte Rechnungs-PLZ #${stop.billingPostalId}` } : null)} describe={v => v.label} disabled={disabled} onSelect={value => { setBilling(value); update({ billingPostalId: value?.id ?? null }); }} />
<p className="form-hint">Ohne Auswahl wird die Rechnungs-PLZ anhand der Adresse ermittelt.</p>
</details>
<div className="form-grid cols-3">
<Field label="Firma / Name *"><input required maxLength={50} value={stop.name} onChange={e => update({ name: e.target.value })} disabled={disabled} /></Field>
<Field label="Straße *"><input required maxLength={50} value={stop.street} onChange={e => update({ street: e.target.value })} disabled={disabled} /></Field>
<Field label="Hausnummer *"><input required maxLength={10} value={stop.houseNumber} onChange={e => update({ houseNumber: e.target.value })} disabled={disabled} /></Field>
<Field label="PLZ *"><input required maxLength={10} value={stop.postalCode} onChange={e => update({ postalCode: e.target.value })} disabled={disabled} /></Field>
<Field label="Ort *"><input required maxLength={50} value={stop.city} onChange={e => update({ city: e.target.value })} disabled={disabled} /></Field>
<Field label="Land (ISO-Code) *"><input required pattern="[A-Z]{2}" maxLength={2} value={stop.country} onChange={e => update({ country: e.target.value.toUpperCase() })} disabled={disabled} /></Field>
<Field label="Ansprechpartner"><input maxLength={50} value={stop.contact} onChange={e => update({ contact: e.target.value })} disabled={disabled} /></Field>
<Field label="Telefon"><input type="tel" maxLength={20} value={stop.phone} onChange={e => update({ phone: e.target.value })} disabled={disabled} /></Field>
<Field label="Stationsreferenz"><input data-entry-key="reference" maxLength={30} value={stop.reference} onChange={e => update({ reference: e.target.value })} disabled={disabled} /></Field>
{photos && <Field label="Fotoworkflow"><select value={stop.photoWorkflow ?? ""} disabled={disabled} onChange={e => update({ photoWorkflow: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">Set-Vorgabe · {defaultPhoto(photoFlows(photos,stop.type),index,count)?.name ?? "Keine Stationsvorgabe"}</option>
{photoFlows(photos,stop.type).map(flow=><option key={flow.number} value={flow.number}>{photoLabel(flow)}</option>)}
</select></Field>}
<Field label="Hinweis zur Station" wide><textarea data-entry-key="note" rows={2} maxLength={2000} value={stop.note} onChange={e => update({ note: e.target.value })} disabled={disabled} /></Field>
</div>
<OrderMapPicker url={mapUrl} station={{ name: stop.name, street: stop.street, houseNumber: stop.houseNumber, postalCode: stop.postalCode, city: stop.city, country: stop.country }} disabled={disabled} onChange={values => update({ ...values, billingPostalId: null })} onOpenChange={onMapOpenChange} />
{stop.street && <a className="text-button" href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${stop.street} ${stop.houseNumber}, ${stop.postalCode} ${stop.city}, ${stop.country}`)}`} target="_blank" rel="noreferrer">Adresse auf Karte anzeigen</a>}
<OrderAttachments onOpenChange={onUploadOpenChange} station={index + 1} url={uploadUrl} files={stop.attachments} disabled={disabled} onChange={attachments => update({ attachments })} />
</section>;
}
export function OrderEntryForm({ onClose, onCreated }: { onClose: () => void; onCreated: (order: Created) => void }) {
const [branches, setBranches] = useState<Option[]>([]), [hq, setHq] = useState<number | null>(null), [error, setError] = useState("");
useEffect(() => { let active = true; apiRequest<{ currentId: number; items: Option[] }>("/orders/entry/headquarters").then(data => { if(active) { setBranches(data.items); setHq(data.currentId); } }).catch((e: Error) => { if(active) setError(e.message); }); return () => { active = false; }; }, []);
if(hq === null) return <Modal title="Neuen Auftrag erfassen" onClose={onClose}><div className="modal-body"><p role={error ? "alert" : "status"}>{error || "Niederlassungsfreigaben werden geladen …"}</p></div></Modal>;
return <EntryScope.Provider key={hq} value={hq}><OrderEntryEditor branches={branches} headquarters={hq} onHeadquarters={setHq} onClose={onClose} onCreated={onCreated} /></EntryScope.Provider>;
}
function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadquarters }: { onClose: () => void; onCreated: (order: Created) => void; branches: Option[]; headquarters: number; onHeadquarters: (id: number) => void }) {
const { request, post } = useEntryApi();
const formRef = useRef<HTMLFormElement>(null);
const [clearStation, setClearStation] = useState<string | null>(null);
const [switchTo, setSwitchTo] = useState<number | null>(null);
const [sustainabilityMode, setSustainabilityMode] = useState("NONE");
const [options, setOptions] = useState<Options | null>(null), [optionsError, setOptionsError] = useState(""), [loadCount, setLoadCount] = useState(0);
const [mode, setMode] = useState<Mode>("IMMEDIATE"), [pickupAt, setPickupAt] = useState(""), [warningAt, setWarningAt] = useState("");
const [rhythm, setRhythm] = useState(1), [weekdays, setWeekdays] = useState("1111100"), [endDate, setEndDate] = useState("");
const [payer, setPayer] = useState<CostCenter | null>(null), [payment, setPayment] = useState("INVOICE"), [cashStop, setCashStop] = useState(1);
const [stops, setStops] = useState<EditorStation[]>(() => [newStop("PICKUP"), newStop("DELIVERY")]);
const [vehicle, setVehicle] = useState<Vehicle | null>(null), [vehicleType, setVehicleType] = useState(""), [manual, setManual] = useState(false);
const [priceMode, setPriceMode] = useState("TARIFF"), [taxId, setTaxId] = useState(""), [quote, setQuote] = useState<Quote | null>(null);
const [photoSet, setPhotoSet] = useState<number | null>(null);
const [photos, setPhotos] = useState<PhotoOptions | null>(null), [photoError, setPhotoError] = useState(""), [photoRetry, setPhotoRetry] = useState(0);
const [confirmation, setConfirmation] = useState<{ kind: "close" } | { kind: "load"; order: SavedOrder } | null>(null);
const loadedServices = useRef<ServiceItem[] | null>(null);
const [dirty, setDirty] = useState(false), [origin, setOrigin] = useState(1), [offer, setOffer] = useState(false), [insured, setInsured] = useState(false), [allCouriers, setAllCouriers] = useState(false);
const [defaults, setDefaults] = useState<CustomerDefaults | null>(null), [customerError, setCustomerError] = useState("");
const [services, setServices] = useState<ServiceOption[]>([]), [serviceItems, setServiceItems] = useState<ServiceItem[]>([]);
const [filters, setFilters] = useState<{ code: string; label: string; mandatory: boolean }[]>([]), [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [openUploads, setOpenUploads] = useState<Set<string>>(() => new Set());
const [features, setFeatures] = useState({ mapUrl: "", bookStock: false });
const [costSplit, setCostSplit] = useState(false), [courierShares, setCourierShares] = useState<CourierDraft[]>([]), [articles, setArticles] = useState<ArticleDraft[]>([]);
const [uploadUrl, setUploadUrl] = useState("");
const [draft, setDraft] = useState<Record<string, string | number>>({}), [formKey, setFormKey] = useState(0), [source, setSource] = useState<SavedOrder | null>(null), [templateOnly, setTemplateOnly] = useState(true);
const [longhaul, setLonghaul] = useState("AUTO");
const [error, setError] = useState(""), [busy, setBusy] = useState(false), [uncertain, setUncertain] = useState(false);
const quoteView = useRef<HTMLDivElement>(null), errorView = useRef<HTMLDivElement>(null);
useEffect(() => { if (quote) quoteView.current?.scrollIntoView({ block: "nearest" }); }, [quote]);
useEffect(() => { if (error) errorView.current?.scrollIntoView({ block: "nearest" }); }, [error]);
const requestId = useRef(crypto.randomUUID()), pending = useRef<Record<string, unknown> | null>(null), inFlight = useRef(false);
const locked = busy || uncertain;
const close = useCallback(() => { if (!inFlight.current && !pending.current) { if (dirty) setConfirmation({ kind: "close" }); else onClose(); } }, [onClose, dirty]);
useEffect(() => {
let active = true;
setOptionsError("");
request<Options>("/orders/entry/options").then(value => {
if (!active) return; setOptions(value);
setTaxId(String(value.taxes.find(tax => tax.id === 4)?.id ?? value.taxes[0]?.id ?? ""));
}).catch((e: Error) => { if (active) setOptionsError(e.message); });
return () => { active = false; };
}, [loadCount]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); };
if (locked || dirty) window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [locked, dirty]);
useEffect(() => {
let active = true;
setPhotos(null); setPhotoSet(null); setPhotoError(""); setQuote(null);
setStops(items => items.map(stop => ({ ...stop, photoWorkflow: null })));
if (!payer) return;
request<PhotoOptions>(`/orders/entry/cost-centers/${payer.id}/photo-workflows`)
.then(value => { if (active) setPhotos(value); })
.catch((e: Error) => { if (active) setPhotoError(e.message); });
return () => { active = false; };
}, [payer?.id, photoRetry]);
useEffect(() => {
request<typeof features>("/orders/entry/features").then(setFeatures).catch((e: Error) => setError(e.message));
request<{ url: string }>("/orders/entry/upload").then(value => setUploadUrl(value.url)).catch((e: Error) => setError(e.message));
request<typeof filters>("/orders/entry/courier-filters").then(setFilters).catch((e: Error) => setError(e.message));
}, []);
useEffect(() => {
let active = true; setDefaults(null); setServices([]); setServiceItems([]); setCustomerError("");
if (!payer) return;
if (payer.taxId) setTaxId(String(payer.taxId));
Promise.all([request<CustomerDefaults>(`/orders/entry/cost-centers/${payer.id}/defaults`), request<ServiceOption[]>(`/orders/entry/cost-centers/${payer.id}/services`)])
.then(([value, items]) => { if (active) { setDefaults(value); setServices(items); if (loadedServices.current) { setServiceItems(loadedServices.current); loadedServices.current = null; } if (value.calculationRequired) setPriceMode("SERVICES"); } })
.catch((e: Error) => { if (active) setCustomerError(e.message); });
return () => { active = false; };
}, [payer?.id, photoRetry]);
const invalidate = () => { setDirty(true); setQuote(null); setError(""); };
async function loadOrder(value: SavedOrder | null, replace = false) {
if (!value) return;
if (dirty && !replace) { setConfirmation({ kind: "load", order: value }); return; }
setBusy(true);
try {
const data = await request<Record<string, unknown> & { payer: CostCenter; stops: Station[]; services: ServiceItem[]; articles?: { article: ArticleDraft["article"]; freeArticle?: ArticleDraft["freeArticle"]; station: number; quantity: number; remark: string }[] }>(`/orders/entry/saved-orders/${value.id}`);
const fields: Record<string, string | number> = {};
for (const [key, v] of Object.entries(data)) if (typeof v === "string" || typeof v === "number") fields[key] = v;
setCostSplit(Boolean(data.costSplit)); setCourierShares([]); setArticles([]);
setDraft(fields); setFormKey(k => k + 1); setSource(value); loadedServices.current = data.payer.id !== payer?.id ? data.services : null; setPayer(data.payer);
const loadedStops = data.stops.map(s => ({ ...s, attachments: s.attachments ?? [], key: crypto.randomUUID() }));
setStops(loadedStops);
setArticles((data.articles ?? []).map(item => ({ key: crypto.randomUUID(), article: item.article, freeArticle: item.freeArticle, stationKey: loadedStops[item.station - 1]?.key ?? "", quantity: item.quantity, serialNumber: "", remark: item.remark, book: false, from: null, to: null })));
setSustainabilityMode("NONE"); setVehicleType(String(data.vehicleTypeId)); setVehicle(null); setManual(false); setMode("IMMEDIATE"); setPayment(String(data.payment)); setCashStop(1);
setPriceMode(data.services?.length ? "SERVICES" : Number(data.fixedPrice) > 0 ? "FIXED" : "TARIFF"); setOrigin(1); setOffer(false); setInsured(false); setAllCouriers(false); setSelectedFilters([]); setServiceItems(data.services ?? []);
requestId.current = crypto.randomUUID(); invalidate();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
const updateStop = (key: string, values: Partial<EditorStation>) => { invalidate(); setStops(items => items.map(s => s.key === key ? { ...s, ...values } : s)); };
const changeRhythm = (value: number) => { setRhythm(value); if (value <= 3) setWeekdays(value === 1 ? "1111100" : value === 2 ? "1111110" : "1111111"); else if (value === 9) setWeekdays("0000000"); };
async function save(payload: Record<string, unknown>) {
pending.current = payload;
try {
const created = await post<Created>("/orders", payload);
pending.current = null; setUncertain(false); onCreated(created);
} catch (e) {
const ambiguous = !(e instanceof ApiError) || e.status === 0 || e.status >= 500;
setUncertain(ambiguous);
if (!ambiguous) { pending.current = null; if (e instanceof ApiError && e.status === 409) setQuote(null); }
setError(ambiguous ? "Die Speicherbestätigung fehlt. Bitte den Speicherstatus mit derselben Anfrage prüfen; es wird kein zweiter Auftrag angelegt." : (e as Error).message);
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inFlight.current) return;
if (openUploads.size) { setError("Bitte die Karten- und Dateiauswahl abschließen und schließen."); return; }
const intent = (event.nativeEvent as SubmitEvent).submitter?.getAttribute("value");
const form = new FormData(event.currentTarget);
if (!payer) { setError("Bitte eine zahlende Kostenstelle auswählen."); return; }
const text = (key: string) => String(form.get(key) || "");
const numeric = (key: string) => text(key) === "" ? null : Number(text(key));
if (courierShares.some(row => !row.vehicle || !Number(row.amount))) { setError("Bitte Fahrzeug und Betrag für alle Unternehmer angeben."); return; }
if (articles.some(row => (!row.article && !row.freeArticle) || !stops.some(stop => stop.key === row.stationKey) || row.book && (!row.from || !row.to))) { setError("Bitte Artikel, Station und gegebenenfalls Quell-/Ziellager vollständig auswählen."); return; }
const payload = {
mode, pickupAt: mode === "IMMEDIATE" ? null : pickupAt + ":00", warningAt: warningAt ? warningAt + ":00" : null,
recurrence: mode === "RECURRING" ? { rhythm, weekdays, endDate: endDate || null } : null,
payerCostCenterId: payer.id, photoSet, payment, cashStop: payment === "CASH" ? cashStop : null,
courierVehicleId: vehicle?.id || null, vehicleTypeId: Number(vehicleType),
stops: stops.map(({ key: _key, ...station }) => station), orderer: text("orderer"), reference: text("reference"), notes: text("notes"), dispatchNote: text("dispatchNote"),
weight: numeric("weight"), length: numeric("length"), width: numeric("width"), height: numeric("height"), positions: numeric("positions"), distanceKm: numeric("distanceKm"),
longhaul, manualDispatch: manual, confirmCourierLimit: intent === "save" && !!quote?.courierLimitExceeded, incomplete: form.get("incomplete") === "on",
pricing: { mode: priceMode, fixedPrice: priceMode === "FIXED" ? numeric("fixedPrice") : null, servicePrice: numeric("servicePrice"),
courierPrice: numeric("courierPrice"), courierServicePrice: numeric("courierServicePrice"), markup: numeric("markup"), courierMarkup: numeric("courierMarkup"),
applyFixedDiscount: form.get("applyFixedDiscount") === "on", taxId: Number(taxId) },
origin, originOther: origin === 6 ? text("originOther") : null, offer, toll: numeric("toll"), invoiceOnlyText: text("invoiceOnlyText"),
allCouriers, courierFilters: selectedFilters, cancelIfUnavailable: form.get("cancelIfUnavailable") === "on", acceptUntil: text("acceptUntil") ? text("acceptUntil") + ":00" : null,
allOccurrences: form.get("allOccurrences") === "on", acceptUnverifiedAddresses: form.get("acceptUnverifiedAddresses") === "on", copies: numeric("copies") ?? 1,
tourName: text("tourName"), overwriteTemplateId: form.get("overwriteTemplate") === "on" ? source?.id : null, sourceOrderId: source?.id ?? null,
insurance: insured ? { goodsValue: numeric("goodsValue"), rate: numeric("insuranceRate"), specialCase: form.get("insuranceSpecial") === "on" } : null,
costSplit, courierShares: courierShares.map(row => ({ vehicleId: row.vehicle!.id, amount: Number(row.amount) })),
articles: articles.map(row => ({ articleId: row.article?.id ?? 0, freeArticle: row.freeArticle ?? null, station: stops.findIndex(s => s.key === row.stationKey) + 1, quantity: row.quantity, serialNumber: row.serialNumber, remark: row.remark, booking: row.book ? { fromStockId: row.from!.id, toStockId: row.to!.id } : null })),
sustainability: mode === "RETROSPECTIVE" && sustainabilityMode !== "NONE" ? { mode: sustainabilityMode, distanceKm: sustainabilityMode === "MANUAL" ? numeric("sustainabilityKm") : null, co2eKg: sustainabilityMode === "MANUAL" ? numeric("co2eKg") : null } : null,
services: serviceItems, invoiceEmail: text("invoiceEmail"), updateInvoiceEmail: form.get("updateInvoiceEmail") === "on",
requestId: requestId.current, quoteFingerprint: quote?.fingerprint || null,
};
inFlight.current = true; setBusy(true); setError("");
try {
if (intent === "save" && quote) await save(payload);
else setQuote(await post<Quote>("/orders/quote", payload));
} catch (e) { setQuote(null); setError((e as Error).message); }
finally { inFlight.current = false; setBusy(false); }
}
async function retry() {
if (inFlight.current || !pending.current) return;
inFlight.current = true; setBusy(true);
try { await save(pending.current); } finally { inFlight.current = false; setBusy(false); }
}
const clearSide = (side: "pickup" | "delivery") => {
const candidates = stops.filter(s => s.type === "BOTH" || s.type === (side === "pickup" ? "PICKUP" : "DELIVERY"));
const station = side === "pickup" ? candidates[0] : candidates.at(-1);
if (station) setClearStation(station.key);
};
const navigateStation = (delta: number, swap = false) => {
const nodes = Array.from(formRef.current?.querySelectorAll<HTMLElement>("[data-station-key]") ?? []);
if (!nodes.length) return;
const index = Math.max(0, nodes.findIndex(node => node.contains(document.activeElement)));
const other = Math.min(nodes.length - 1, Math.max(0, index + delta));
if (swap && other !== index) {
invalidate(); setStops(items => { const next = [...items]; [next[index], next[other]] = [next[other], next[index]]; return next; });
if (payment === "CASH") setCashStop(value => value === index + 1 ? other + 1 : value === other + 1 ? index + 1 : value);
}
nodes[other].querySelector<HTMLElement>("input[required]")?.focus();
};
useOrderEntryShortcuts(formRef, locked || !!confirmation || switchTo !== null || clearStation !== null || openUploads.size > 0, {
e: () => { invalidate(); setMode("RETROSPECTIVE"); setManual(false); setAllCouriers(false); setOffer(false); },
l: () => clearSide("pickup"), r: () => clearSide("delivery"),
x: () => navigateStation(1), y: () => navigateStation(-1), z: () => navigateStation(1, true),
s: () => { const button = formRef.current?.querySelector<HTMLButtonElement>(quote ? 'button[value="save"]' : 'button[value="quote"]'); if (button && !button.disabled) button.click(); },
});
return <><Modal title="Neuen Auftrag erfassen" subtitle="Kostenstelle, Stationen, Termin und Preis · Zeiten in Europe/Berlin" wide onClose={close}>
{!options ? <div className="modal-body">{optionsError ? <><p role="alert" className="alert error">{optionsError}</p><button className="button" onClick={() => setLoadCount(c => c + 1)}>Erneut laden</button></> : <p role="status"><LoaderCircle className="spin" size={16} /> Erfassungsdaten werden geladen …</p>}</div> : <form ref={formRef} key={formKey} onSubmit={submit} onChange={invalidate} onInvalidCapture={event => { const details = (event.target as HTMLElement).closest("details"); if (details) details.open = true; }}>
<div className="modal-body">
<details className="entry-shortcuts"><summary>Tastenkürzel · Alt + Buchstabe</summary>
<p className="form-hint">Gilt nur in dieser Erfassung. Auf dem Mac die Wahltaste (⌥) verwenden. Gesperrte Aktionen und geöffnete Unterdialoge bleiben geschützt.</p>
<dl className="entry-shortcut-list">{[["A", "An alle Kuriere"], ["C", "Auftrag kopieren"], ["D", "Zahler suchen/ändern"], ["E", "Nacherfassung"], ["F", "Ungeprüfte Adressen"], ["G / H", "Abhol-/Zustelladresse suchen"], ["I", "Kurierfilter"], ["J", "Unvollständig"], ["K", "Fahrzeug suchen/ändern"], ["L / R", "Abhol-/Zustelldaten leeren (mit Rückfrage)"], ["M", "Manuelle Disposition"], ["V / N", "Abhol-/Zustellkostenstelle"], ["O / P", "Stationshinweise"], ["Q", "Rechnungstext"], ["U / W", "Stationsreferenzen"], ["X / Y", "Nächste/vorherige Station"], ["Z", "Aktuelle und nächste Station vertauschen"], ["S", "Preis berechnen; bei gültiger Vorschau speichern"]].map(([key, label]) => <div key={key}><dt><kbd>{key}</kbd></dt><dd>{label}</dd></div>)}</dl>
</details>
<Field label="Erfassungsniederlassung"><select value={headquarters} disabled={locked || openUploads.size > 0 || branches.length < 2} onChange={e => { const id = Number(e.target.value); if(dirty) setSwitchTo(id); else onHeadquarters(id); }}>{branches.map(branch => <option key={branch.id} value={branch.id}>{branch.label}</option>)}</select></Field>
<p className="form-hint">Zahler, Tarife, Artikel und Fahrzeuge gehören zur gewählten Niederlassung. Ein Wechsel beginnt eine neue Eingabe; andere Browser-Tabs bleiben unverändert.</p>
<details className="entry-copy"><summary data-entry-key="c">Auftrag kopieren / gespeicherte Tour laden</summary>
<label className="entry-checkbox"><input type="checkbox" checked={templateOnly} disabled={locked} onChange={e => setTemplateOnly(e.target.checked)} />Nur gespeicherte Touren anzeigen</label>
<Lookup<SavedOrder> label="Vorlage / Auftragsnummer" endpoint={`/orders/entry/saved-orders?templates=${templateOnly}${payer ? `&payerId=${payer.id}` : ""}`} value={null} describe={v => `${v.id} · ${v.name || "Auftrag"} · ${v.customer}`} disabled={locked} onSelect={value => void loadOrder(value)} />
{source && <p className="form-hint">Übernommen aus Auftrag {source.id}. Termin, Fotoauswahl, Kassierstation und Preis vor dem Speichern prüfen. Artikel-Seriennummern, Lagerbuchungen und Kurier-Unteraufträge bei Bedarf neu erfassen.</p>}
</details>
<section className="form-section"><h3><span>01</span>Auftrag und Zahler</h3>
<div className="form-grid cols-3">
<Field label="Auftragsart"><select value={mode} disabled={locked} onChange={e => { setMode(e.target.value as Mode); if (e.target.value === "RETROSPECTIVE") { setManual(false); setAllCouriers(false); setOffer(false); } if (e.target.value === "RECURRING") setOffer(false); }}><option value="IMMEDIATE">Sofortauftrag</option><option value="RESERVATION">Vorbestellung</option><option value="RECURRING">Dauerauftrag</option><option value="RETROSPECTIVE">Nacherfassung</option></select></Field>
<Field label="Zahlungsart"><select value={payment} disabled={locked} onChange={e => { setPayment(e.target.value); if (e.target.value === "CASH") setCostSplit(false); }}><option value="INVOICE">Rechnung</option><option value="CASH">Barzahlung</option></select></Field>
<Field label="Besteller *"><input required name="orderer" defaultValue={draft.orderer ?? ""} maxLength={50} disabled={locked} /></Field>
</div>
<Lookup shortcut="d" label={payment === "CASH" ? "Tatsächlicher Barzahler / Kostenstelle" : "Rechnungszahler / Kostenstelle"} endpoint="/orders/entry/cost-centers" value={payer} describe={costCenterLabel} disabled={locked} onSelect={value => { invalidate(); setPayer(value); }} />
<label className="entry-checkbox"><input type="checkbox" checked={costSplit} disabled={locked || payment !== "INVOICE"} onChange={e => setCostSplit(e.target.checked)} />Kosten gleichmäßig auf Rechnungskostenstellen aufteilen</label>
{costSplit && <p className="form-hint">Beteiligt sind die unterschiedlichen, internen Kostenstellen der Stationen und der ausgewählte Rechnungszahler. Externe Adresskostenstellen zählen nicht mit. Tarif und Steuer richten sich nach dem ausgewählten Zahler; die Aufteilung erscheint in der Preisvorschau.</p>}
{payer?.defaultPayerId && payer.defaultPayerId !== payer.id && <button type="button" className="text-button" disabled={locked} onClick={async () => { try { const values = await request<CostCenter[]>(`/orders/entry/cost-centers?q=${payer.defaultPayerId}`); const found = values.find(v => v.id === payer.defaultPayerId); if (!found) throw new Error("Die hinterlegte Zahlerkostenstelle ist nicht verfügbar."); invalidate(); setPayer(found); } catch (e) { setError((e as Error).message); } }}>Hinterlegte Zahlerkostenstelle übernehmen</button>}
<div className="form-grid cols-3">
<Field label={`Referenz / Kommissionsnummer${defaults?.referenceRequired ? " *" : ""}`}><input required={defaults?.referenceRequired} name="reference" defaultValue={draft.reference ?? ""} maxLength={50} disabled={locked} /></Field>
{mode !== "IMMEDIATE" && <Field label={mode === "RETROSPECTIVE" ? "Ausführungszeitpunkt *" : "Abholtermin / Serienbeginn *"}><input type="datetime-local" value={pickupAt} onChange={e => setPickupAt(e.target.value)} required disabled={locked} /></Field>}
<Field label="Warnzeit"><input type="datetime-local" value={warningAt} onChange={e => setWarningAt(e.target.value)} disabled={locked} /></Field>
</div>
<div className="form-grid cols-3"><Field label="Auftragseingang"><select value={origin} disabled={locked} onChange={e => setOrigin(Number(e.target.value))}><option value={1}>Telefon</option><option value={2}>Mail</option><option value={6}>Sonstiges</option></select></Field>
{origin === 6 && <Field label="Sonstige Herkunft *"><input name="originOther" required maxLength={40} disabled={locked} /></Field>}
<Field label="Gleiche Aufträge anlegen"><select name="copies" disabled={locked}>{Array.from({ length: 10 }, (_, i) => <option key={i + 1} value={i + 1}>{i + 1}</option>)}</select></Field>
</div>
{customerError && <p className="alert error" role="alert">{customerError} <button className="text-button" type="button" onClick={() => setPhotoRetry(v => v + 1)}>Kundenvorgaben erneut laden</button></p>}
{defaults && <details><summary>Rechnungsmail und Kundenvorgaben</summary><Field label="Rechnungsmail"><input key={payer?.id} type="email" name="invoiceEmail" defaultValue={defaults.invoiceEmail} maxLength={254} disabled={locked} /></Field>
<label className="entry-checkbox"><input type="checkbox" name="updateInvoiceEmail" disabled={locked} />Rechnungsmail im Kundenstamm aktualisieren</label>
<p className="form-hint">{defaults.vatId ? `Umsatzsteuer-ID: ${defaults.vatId}. ` : "Keine Umsatzsteuer-ID hinterlegt. "}{defaults.calculationRequired && "Leistungskalkulation ist für diesen Kunden Pflicht."}</p>
</details>}
<label className="entry-checkbox"><input type="checkbox" checked={offer} disabled={locked || mode === "RECURRING" || mode === "RETROSPECTIVE" || insured} onChange={e => { setOffer(e.target.checked); if (e.target.checked) { setManual(true); setAllCouriers(false); setVehicle(null); } }} />Als Angebot erfassen</label>
{mode === "RETROSPECTIVE" && <p className="form-hint">Der Auftrag und alle Stationen werden als erledigt erfasst. Annahme und Abschluss erhalten den Ausführungszeitpunkt.</p>}
{mode === "RECURRING" && <div className="entry-recurrence"><div className="form-grid"><Field label="Wiederholung"><select value={rhythm} disabled={locked} onChange={e => changeRhythm(Number(e.target.value))}>{options.rhythms.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}</select></Field><Field label="Serienende (optional)"><input type="date" value={endDate} min={pickupAt.slice(0, 10)} disabled={locked} onChange={e => setEndDate(e.target.value)} /></Field></div>
{(rhythm <= 3 || rhythm === 9) && <div className="entry-weekdays">{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map((day, index) => <label key={day}><input type="checkbox" checked={weekdays[index] === "1"} disabled={locked} onChange={e => setWeekdays(value => value.slice(0, index) + (e.target.checked ? "1" : "0") + value.slice(index + 1))} />{day}</label>)}</div>}
<label className="entry-checkbox"><input name="allOccurrences" type="checkbox" disabled={locked || !endDate} />Alle Folgeaufträge bis zum Serienende sofort anlegen</label>
<p className="form-hint">Folgeaufträge und Feiertagsverschiebungen werden durch die bestehende Dauerauftragsverarbeitung erzeugt.</p>
</div>}
</section>
<section className="form-section"><div className="form-section-header"><h3><span>02</span>Transportstationen</h3><button className="text-button" type="button" disabled={locked || stops.length >= 100} onClick={() => { invalidate(); setStops(items => [...items, newStop("DELIVERY")]); }}><Plus size={15} />Station hinzufügen</button></div>
{payer && !photos && !photoError && <p role="status" className="form-hint">Fotoworkflows werden geladen …</p>}
{photoError && <div className="alert error" role="alert">{photoError} <button type="button" className="text-button" disabled={locked} onClick={() => setPhotoRetry(value => value + 1)}>Fotoworkflows erneut laden</button></div>}
{photos?.configured && photos.sets.length === 0 && <p className="form-hint">Für diesen Kunden sind keine Foto-Sets freigegeben.</p>}
{!!photos?.sets.length && <Field label="Foto-Set"><select value={photoSet ?? ""} disabled={locked} onChange={e=>{invalidate();setPhotoSet(e.target.value===""?null:Number(e.target.value));setStops(items=>items.map(stop=>({...stop,photoWorkflow:null})));}}><option value="">Kundenstandard · {(photos.sets.find(s=>s.defaultSet)??photos.sets[0]).name}</option>{photos.sets.map(s=><option key={s.number} value={s.number}>{s.name}</option>)}</select></Field>}
{stops.map((stop, index) => <StopEditor key={stop.key} stop={stop} index={index} count={stops.length} disabled={locked} photos={photos?.sets.find(s=>s.number===photoSet)??(photoSet===null?(photos?.sets.find(s=>s.defaultSet)??photos?.sets[0]??null):null)} uploadUrl={uploadUrl} mapUrl={features.mapUrl} onMapOpenChange={open => setOpenUploads(keys => { const key = `map:${stop.key}`; if (keys.has(key) === open) return keys; const next = new Set(keys); if (open) next.add(key); else next.delete(key); return next; })} onUploadOpenChange={open => setOpenUploads(keys => { if (keys.has(stop.key) === open) return keys; const next = new Set(keys); if (open) next.add(stop.key); else next.delete(stop.key); return next; })} update={values => updateStop(stop.key, values.type ? {...values,photoWorkflow:null}:values)} move={delta => { invalidate(); setStops(items => { const next = [...items]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; return next; }); if (payment === "CASH") { if (cashStop === index + 1) setCashStop(index + delta + 1); else if (cashStop === index + delta + 1) setCashStop(index + 1); } }} remove={() => { invalidate(); setStops(items => items.filter(s => s.key !== stop.key)); setCashStop(value => value > index + 1 ? value - 1 : value === index + 1 ? 1 : value); }} />)}
{!!options.euCountries?.length && stops.some(stop => stop.country !== "DE" && !options.euCountries?.includes(stop.country)) && <p className="alert" role="status">Bestandshinweis für Nicht-EU-Transporte: Schriftliche Bestätigung der Versender-Eigenschaft nach der Handlungsanweisung Drittland anfordern.</p>}
<label className="entry-checkbox"><input data-entry-key="f" type="checkbox" name="acceptUnverifiedAddresses" disabled={locked} />Ungeprüfte Adressen ausdrücklich übernehmen</label>
<p className="form-hint">Neue Adressen werden geprüft. Bei einer fehlgeschlagenen Prüfung kann die Übernahme ausdrücklich bestätigt werden; automatische Routenpreise benötigen weiterhin gültige Koordinaten.</p>
{payment === "CASH" && <Field label="Kassieren an Station *"><select value={cashStop} disabled={locked} onChange={e => setCashStop(Number(e.target.value))}>{stops.map((s, i) => <option key={s.key} value={i + 1}>{i + 1} · {s.name || "Station"}</option>)}</select></Field>}
</section>
<section className="form-section"><h3><span>03</span>Fahrzeug und Sendung</h3>
<div className="form-grid"><Field label="Fahrzeugtyp *"><select required value={vehicleType} disabled={locked} onChange={e => setVehicleType(e.target.value)}><option value="">Bitte wählen</option>{options.vehicleTypes.map(v => <option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
{mode !== "RETROSPECTIVE" && <label className="entry-checkbox"><input data-entry-key="m" type="checkbox" checked={manual} disabled={locked || !!vehicle || offer || allCouriers} onChange={e => setManual(e.target.checked)} />Manuell disponieren</label>}
</div>
{mode !== "RETROSPECTIVE" && <label className="entry-checkbox"><input data-entry-key="a" type="checkbox" checked={allCouriers} disabled={locked || manual || offer || !!vehicle} onChange={e => setAllCouriers(e.target.checked)} />An alle geeigneten Kuriere verteilen</label>}
{!manual && !allCouriers && <Lookup shortcut="k" label={mode === "RETROSPECTIVE" ? "Ausführendes Fahrzeug *" : "Fahrzeug direkt zuweisen (optional)"} endpoint="/orders/entry/vehicles" value={vehicle} describe={vehicleLabel} disabled={locked} onSelect={value => { invalidate(); setVehicle(value); if (value && !vehicleType) setVehicleType(String(value.vehicleTypeId)); }} />}
<div className="form-grid cols-3">{[["weight", "Gewicht (kg)"], ["length", "Länge (cm)"], ["width", "Breite (cm)"], ["height", "Höhe (cm)"], ["positions", "Stellplätze"], ["distanceKm", "Entfernung (km)"]].map(([name, label]) => <Field key={name} label={label}><input type="number" name={name} defaultValue={draft[name] ?? ""} min="0" max={name === "weight" || name === "distanceKm" ? "999999" : "9999"} step={name === "distanceKm" ? "0.01" : "1"} disabled={locked} /></Field>)}</div>
<Field label="Rechnungstext"><textarea data-entry-key="q" name="notes" defaultValue={draft.notes ?? ""} maxLength={4000} rows={2} disabled={locked} /></Field><Field label="Dispo-Info"><input name="dispatchNote" defaultValue={draft.dispatchNote ?? ""} maxLength={120} disabled={locked} /></Field>
<Field label="Text nur für die Kundenrechnung"><textarea name="invoiceOnlyText" defaultValue={draft.invoiceOnlyText ?? ""} maxLength={4000} rows={2} disabled={locked} /></Field>
<details><summary data-entry-key="i">Kurierfilter und Annahmefrist</summary>
<div className="entry-filter-list">{filters.map(filter => <label className="entry-checkbox" key={filter.code}><input type="checkbox" checked={selectedFilters.includes(filter.code)} disabled={locked} onChange={e => setSelectedFilters(values => e.target.checked ? [...values, filter.code] : values.filter(v => v !== filter.code))} />{filter.code} · {filter.label}{filter.mandatory ? " (Anforderung)" : ""}</label>)}</div>
<Field label="Annahme bis"><input type="datetime-local" name="acceptUntil" disabled={locked} /></Field>
<label className="entry-checkbox"><input type="checkbox" name="cancelIfUnavailable" disabled={locked} />Storno bei Nichtverfügbarkeit</label>
<p className="form-hint">Hinterlegte Kunden- und Pflichtfilter werden zusätzlich angewendet.</p>
</details>
<label className="entry-checkbox"><input data-entry-key="j" type="checkbox" name="incomplete" disabled={locked} />Zur Nachbearbeitung als unvollständig kennzeichnen</label>
</section>
{mode === "RETROSPECTIVE" && <section className="form-section"><h3>Nachhaltigkeitsdaten</h3><Field label="Kilometer und CO₂e"><select value={sustainabilityMode} disabled={locked} onChange={e => setSustainabilityMode(e.target.value)}><option value="NONE">Später über den Bestandsdienst</option><option value="AUTO">Automatisch berechnen</option><option value="MANUAL">Geprüfte Werte manuell erfassen</option></select></Field>
{sustainabilityMode === "AUTO" && <p className="form-hint">Verwendet Fahrzeugdaten und Sendungsgewicht (sonst halbe Nutzlast). Die eingetragene Entfernung hat Vorrang, andernfalls wird die Strecke berechnet. Der CO₂e-Dienst muss konfiguriert sein.</p>}
{sustainabilityMode === "MANUAL" && <div className="form-grid"><Field label="Gefahrene Kilometer *"><input name="sustainabilityKm" type="number" min="0.01" max="999999.99" step="0.01" required disabled={locked} /></Field><Field label="CO₂e gesamt (kg) *"><input name="co2eKg" type="number" min="0.01" max="999999.99" step="0.01" required disabled={locked} /></Field></div>}
</section>}
<section className="form-section"><EntryArticleFields rows={articles} onChange={values => { invalidate(); setArticles(values); }} stops={stops} payerId={payer?.id} canBook={features.bookStock} disabled={locked} allowBooking={!offer && mode !== "RECURRING"} /></section>
<section className="form-section"><h3><span>04</span>Preis und Prüfung</h3>
<div className="form-grid cols-3"><Field label="Preisermittlung"><select value={priceMode} disabled={locked} onChange={e => setPriceMode(e.target.value)}><option value="TARIFF">Bestandstarif (PLZ / Gebiet)</option><option value="FIXED">Festpreis</option><option value="KILOMETER">Automatisch nach Kilometern</option><option value="ZONE">Automatisch nach Tarifzonen (PZM)</option><option value="SERVICES">Leistungskalkulation</option></select></Field>
<Field label="Fernverkehr"><select value={longhaul} disabled={locked} onChange={e => setLonghaul(e.target.value)}><option value="AUTO">Automatisch anhand der Strecke</option><option value="ASAP">ASAP · keinen Fernauftrag anlegen</option></select></Field>
{priceMode === "FIXED" && <Field label="Festpreis netto, vor Zuschlag (€) *"><input required type="number" name="fixedPrice" defaultValue={draft.fixedPrice ?? ""} min="0.01" max="999999.99" step="0.01" disabled={locked} /></Field>}
<Field label="Maut / Auslagen netto (€)"><input name="toll" defaultValue={draft.toll ?? ""} type="number" min="0" max="999999.99" step="0.01" disabled={locked} /></Field>
<Field label="Servicepreis netto (€)"><input type="number" name="servicePrice" defaultValue={draft.servicePrice ?? ""} min="0" max="999999.99" step="0.01" disabled={locked} /></Field>
<Field label="Kundenzuschlag (%)"><input type="number" name="markup" min="-100" max="100" step="0.01" placeholder="Aus Bestand" disabled={locked} /></Field>
<Field label="Steuerschlüssel *"><select required value={taxId} disabled={locked} onChange={e => setTaxId(e.target.value)}><option value="">Bitte wählen</option>{options.taxes.map(t => <option key={t.id} value={t.id}>{t.label} · {t.rate} %</option>)}</select></Field>
{options.editCourierPrice && <><Field label="Frachtvergütung vor Zuschlag (€)"><input type="number" name="courierPrice" min="0" max="999999.99" step="0.01" placeholder="Aus Tarif" disabled={locked} /></Field><Field label="Servicevergütung (€)"><input type="number" name="courierServicePrice" min="0" max="999999.99" step="0.01" placeholder="Aus Tarif" disabled={locked} /></Field><Field label="Kurierzuschlag (%)"><input type="number" name="courierMarkup" min="-100" max="100" step="0.01" placeholder="Aus Bestand" disabled={locked} /></Field></>}
</div><label className="entry-checkbox"><input type="checkbox" name="applyFixedDiscount" disabled={locked} />Hinterlegten Festpreisrabatt anwenden{payer ? ` (${payer.fixedDiscount} %)` : ""}</label>
{(services.length > 0 || defaults?.calculationRequired) && <details open={priceMode === "SERVICES" || defaults?.calculationRequired}><summary>Leistungspositionen</summary>
{serviceItems.map((item, index) => <div className="form-grid entry-service-row" key={index}>
<Field label={`Leistung ${index + 1}`}><select required value={`${item.serviceId}:${item.typeId}`} disabled={locked} onChange={e => { const [serviceId, typeId] = e.target.value.split(":").map(Number); setServiceItems(items => items.map((v, i) => i === index ? { ...v, serviceId, typeId } : v)); }}><option value="0:0">Leistung wählen</option>{services.map(v => <option key={`${v.serviceId}:${v.typeId}`} value={`${v.serviceId}:${v.typeId}`}>{v.service} · {v.type}</option>)}</select></Field>
<Field label="Menge"><input required type="number" min="0.001" max="99999" step="0.001" value={item.quantity} disabled={locked} onChange={e => setServiceItems(items => items.map((v, i) => i === index ? { ...v, quantity: Number(e.target.value) } : v))} /></Field>
<button className="text-button" type="button" disabled={locked} onClick={() => { invalidate(); setServiceItems(items => items.filter((_, i) => i !== index)); }}>Position entfernen</button>
</div>)}
<button className="text-button" type="button" disabled={locked || serviceItems.length >= 100 || !services.length} onClick={() => { invalidate(); setPriceMode("SERVICES"); setServiceItems(items => [...items, { serviceId: 0, typeId: 0, quantity: 1 }]); }}>Leistung hinzufügen</button>
{!services.length && <p className="alert">Für diesen Kunden sind keine verfügbaren Leistungspositionen hinterlegt.</p>}
</details>}
{options.editCourierPrice && <EntryCourierFields rows={courierShares} onChange={values => { invalidate(); setCourierShares(values); }} disabled={locked} enabled={mode === "RETROSPECTIVE" && !!vehicle && stops.every(s => s.country === "DE")} />}
<details><summary>Höherversicherung</summary>
<label className="entry-checkbox"><input type="checkbox" checked={insured} disabled={locked || offer} onChange={e => setInsured(e.target.checked)} />Transportzusatzversicherung anlegen</label>
{insured && <div className="form-grid"><Field label="Warenwert (€) *"><input name="goodsValue" type="number" required min="0.01" max="9999999.99" step="0.01" disabled={locked} /></Field>
<Field label="Versicherungstarif"><select name="insuranceRate" disabled={locked}><option value={1}>Warengruppe 1 · innerdeutsch</option><option value={2}>Warengruppe 2 · innerdeutsch</option><option value={3}>Warengruppe 1 · innereuropäisch</option><option value={4}>Warengruppe 2 · innereuropäisch</option><option value={5}>Innerdeutscher Botendienst</option></select></Field>
<label className="entry-checkbox"><input name="insuranceSpecial" type="checkbox" disabled={locked} />Sonderfall · zur Nachbearbeitung offen halten</label>
</div>}
</details>
<details><summary>Tour als Vorlage speichern</summary><Field label="Name der gespeicherten Tour"><input name="tourName" defaultValue={draft.tourName ?? ""} maxLength={50} disabled={locked} /></Field>
{!!source?.name && <label className="entry-checkbox"><input name="overwriteTemplate" type="checkbox" disabled={locked} />Geladene Tour „{source.name}“ überschreiben</label>}
</details>
{stops[0]?.country !== "DE" && <p className="form-hint">Bei Auslandsabholung wird die disponierende Niederlassung anhand der gesamten Hin- und Rückfahrt ermittelt. Für Auslandsstrecken Kilometer, Tarifzonen oder Festpreis wählen.</p>}
{(priceMode === "KILOMETER" || priceMode === "ZONE") && <p className="form-hint">Die Strecke wird automatisch ermittelt. Ein manuell eingetragener Entfernungswert wird hierfür nicht verwendet.</p>}
{quote ? <div ref={quoteView} className="entry-quote" aria-label="Preisvorschau">
{quote.route && <div className="entry-route" aria-label="Routenvorschau"><p><strong>{quote.route.distanceKm.toLocaleString("de-DE")} km</strong> · {Math.round(quote.route.durationSeconds / 60)} Min. Fahrzeit · Disposition {quote.route.dispatchHeadquarters}</p>
<p>Berechnungsstrecke einschließlich An-/Abfahrt: {quote.route.calculationKm.toLocaleString("de-DE")} km · Tarifniederlassung {quote.route.pricingHeadquartersId}</p>
{quote.route.longhaul ? <p className="alert" role="status">{mode === "RETROSPECTIVE" ? "Nacherfasster Fernauftrag." : "Fernauftrag: Übergabe an die manuelle Disposition. Die Frachtvergütung wird im Fernverkehrsprozess festgelegt."}</p> : quote.route.longhaulEligible && <p className="form-hint">Fernverkehrsgrenze erreicht; auf Wunsch als ASAP erfasst.</p>}
{!!quote.route.comparedHeadquarters.length && <details><summary>Niederlassungsvergleich für Auslandsabholung</summary><ul>{quote.route.comparedHeadquarters.map(h => <li key={h.id}>{h.name}: {h.roundTripKm.toLocaleString("de-DE")} km Hin- und Rückfahrt</li>)}</ul></details>}
</div>}
{!!quote.calculation?.length && <details><summary>Streckenpreis vor Zuschlägen und Rabatt</summary><table><thead><tr><th>Bestandteil</th><th>Menge</th><th>Betrag</th></tr></thead><tbody>{quote.calculation.map((line, i) => <tr key={i}><td>{line.name}</td><td>{line.quantity.toLocaleString("de-DE")}</td><td>{euro(line.amount)}</td></tr>)}</tbody></table></details>}
{quote.photoSet && <p className="form-hint">Foto-Set: {quote.photoSet.name}</p>}
{!!quote.photos?.length && <p className="form-hint">Fotos: {quote.photos.map(photo => `Station ${photo.station}: ${photo.name}`).join(" · ")}</p>}
<table><thead><tr><th>Preisbestandteil</th><th>Rabatt</th><th>Netto</th></tr></thead><tbody>{quote.components.map((c, i) => <tr key={i}><td>{c.name}</td><td>{c.discount} %</td><td>{euro(c.net)}</td></tr>)}</tbody></table>
<div className="form-price"><span>Kundenpreis netto</span><strong>{euro(quote.net)}</strong></div>{!!quote.toll && <div className="form-price"><span>Maut / Auslagen netto</span><strong>{euro(quote.toll)}</strong></div>}<div className="form-price"><span>MwSt ({quote.taxRate} %)</span><strong>{euro(quote.tax)}</strong></div><div className="form-price"><span>{payment === "CASH" ? "Zu kassieren" : "Kundenpreis brutto"}</span><strong>{euro(quote.gross)}</strong></div><div className="form-price"><span>Frachtvergütung netto</span><strong>{euro(quote.courierNet)}</strong></div>
{!!quote.costAllocations?.length && <details open><summary>Kostensplit auf Rechnungsempfänger</summary><table><thead><tr><th>Kostenstelle</th><th>Netto inkl. Auslagen</th><th>MwSt</th><th>Brutto</th></tr></thead><tbody>{quote.costAllocations.map(row => <tr key={row.costCenterId}><td>{row.name}</td><td>{euro(row.net + row.toll)}</td><td>{euro(row.tax)}</td><td>{euro(row.gross)}</td></tr>)}</tbody></table></details>}
{!!quote.courierAllocations?.length && <details open><summary>Unternehmerverrechnung</summary><table><thead><tr><th>SID</th><th>Basisbetrag</th><th>Vergütung netto</th></tr></thead><tbody>{quote.courierAllocations.map(row => <tr key={row.vehicleId}><td>{row.sid}</td><td>{euro(row.base)}</td><td>{euro(row.net)}</td></tr>)}</tbody></table><p className="form-hint">Je Zeile wird zusätzlich eine gleich hohe Gegenbuchung beim ausführenden Kurier erstellt.</p></details>}
{!!quote.articleLines?.length && <details open><summary>Artikel / Lagerbewegungen prüfen</summary><table><thead><tr><th>Station / Artikel</th><th>Menge</th><th>Lagerbuchung</th></tr></thead><tbody>{quote.articleLines.map((row, i) => <tr key={i}><td>{row.station} · {row.name}</td><td>{row.quantity}</td><td>{row.booked ? `${row.source} → ${row.destination} · sofort beim Speichern` : "Keine Lagerbewegung"}</td></tr>)}</tbody></table></details>}
{!!quote.schedule?.length && <details><summary>{quote.schedule.reduce((sum, item) => sum + item.count, 0)} Serientermine prüfen</summary><table><thead><tr><th>Termin (Berlin)</th><th>Anzahl</th><th>Brutto je Auftrag</th></tr></thead><tbody>{quote.schedule.map(item => <tr key={item.pickupAt}><td>{item.pickupAt.replace("T", " ")}</td><td>{item.count}</td><td>{euro(item.gross)}</td></tr>)}</tbody></table></details>}
{!!quote.insurancePremium && <p className="alert">Zusätzlich wird ein verknüpfter Versicherungsauftrag über {euro(quote.insurancePremium)} angelegt (Bestandsberechnung, Rechnungssteuer zusätzlich).</p>}
{(quote.copies ?? 1) > 1 && <p className="alert">Es werden {quote.copies} gleiche Aufträge angelegt. Die angezeigten Preise gelten je Auftrag.</p>}
{quote.sustainability && <p className="alert">Nachhaltigkeit: {quote.sustainability.distanceKm.toLocaleString("de-DE")} km · {quote.sustainability.co2eKg.toLocaleString("de-DE")} kg CO₂e · {quote.sustainability.source}</p>}
{quote.courierLimitExceeded && <p className="alert" role="status">Die Frachtvergütung überschreitet die Grenze von {quote.courierLimit} % des Kundenpreises.{options.overrideCourierLimit ? " Die Überschreitung muss beim Speichern bestätigt werden." : " Für die Überschreitung fehlt die Berechtigung. Bitte die Vergütung korrigieren."}</p>}
<p className="form-hint">Kundenzuschlag {quote.markup} % · Kurierzuschlag {quote.courierMarkup} %. Änderungen erfordern eine neue Preisvorschau.</p>{quote.batch && <p className="alert">Listenkunde: Der Auftrag wird gemäß Kundenvereinbarung mit Nullpreis angelegt.</p>}
</div> : <p className="form-hint">Vor dem Speichern den Preis berechnen und prüfen. Zuschläge, Rabatte und Frachtvergütung werden aus dem Bestand übernommen.</p>}
</section>
{error && <div ref={errorView} className="alert error" role="alert">{error}</div>}
</div>
<div className="modal-footer"><span className="muted">* Pflichtangaben</span><button type="button" className="button" disabled={locked} onClick={close}>Abbrechen</button>
{uncertain ? <button type="button" className="button primary" disabled={busy} onClick={() => void retry()}>Speicherstatus prüfen</button> : <><button type="submit" value="quote" className="button" disabled={busy || openUploads.size > 0 || !payer || !photos || !!photoError || !!customerError || !defaults}>{busy && <LoaderCircle size={16} className="spin" />}Preis berechnen</button><button type="submit" value="save" className="button primary" disabled={busy || openUploads.size > 0 || !quote || !payer || !photos || !!photoError || !!customerError || !defaults || (!!quote?.courierLimitExceeded && !options.overrideCourierLimit)}><Check size={16} />{quote?.courierLimitExceeded ? "Preisgrenze bestätigen & speichern" : "Auftrag speichern"}</button></>}
</div>
</form>}
</Modal>
{clearStation !== null && <Modal title="Stationsdaten leeren?" onClose={() => setClearStation(null)}><div className="modal-body"><p>Adresse, Kostenstelle, Hinweise und Anhänge dieser Station werden aus dem Entwurf entfernt. Die Station und ihre Artikelpositionen bleiben erhalten.</p></div><div className="modal-footer"><button type="button" className="button" onClick={() => setClearStation(null)}>Weiter bearbeiten</button><button type="button" className="button primary" onClick={() => { const old = stops.find(s => s.key === clearStation); if (old) { const replacement = newStop(old.type); invalidate(); setStops(items => items.map(s => s.key === old.key ? replacement : s)); setArticles(items => items.map(item => item.stationKey === old.key ? { ...item, stationKey: replacement.key } : item)); } setClearStation(null); }}>Stationsdaten leeren</button></div></Modal>}
{switchTo !== null && <Modal title="Niederlassung wechseln?" onClose={() => setSwitchTo(null)}><div className="modal-body"><p>Die ungespeicherten Eingaben werden verworfen. Die Erfassung beginnt neu in {branches.find(b => b.id === switchTo)?.label}.</p></div><div className="modal-footer"><button type="button" className="button" onClick={() => setSwitchTo(null)}>Weiter bearbeiten</button><button type="button" className="button primary" onClick={() => onHeadquarters(switchTo)}>Eingaben verwerfen und wechseln</button></div></Modal>}
{confirmation && <Modal title={confirmation.kind === "close" ? "Eingaben verwerfen?" : "Tour übernehmen?"} onClose={() => setConfirmation(null)}>
<div className="modal-body"><p>{confirmation.kind === "close" ? "Die ungespeicherten Auftragseingaben gehen beim Schließen verloren." : "Die aktuelle Eingabe wird durch die gewählte Tour ersetzt."}</p></div>
<div className="modal-footer"><button type="button" className="button" onClick={() => setConfirmation(null)}>Weiter bearbeiten</button><button type="button" className="button primary" onClick={() => { const action = confirmation; setConfirmation(null); if (action.kind === "close") onClose(); else void loadOrder(action.order, true); }}>{confirmation.kind === "close" ? "Eingaben verwerfen" : "Tour übernehmen"}</button></div>
</Modal>}
</>;
}
+55
View File
@@ -0,0 +1,55 @@
import { Field } from "./FormElements";
import { Lookup } from "./EntryLookup";
type Vehicle = { id: number; courierId: number; sid: string; name: string; vehicleTypeId: number; plate: string };
type Article = { id: number; number: string; name: string; serialRequired: boolean; weight: number; packingPieces: number };
type Stock = { id: number; label: string; available: number };
export type CourierDraft = { key: string; vehicle: Vehicle | null; amount: string };
export type FreeArticle = { name: string; totalWeight: number; packingPieces: number; price: number };
export type ArticleDraft = { freeArticle?: FreeArticle; key: string; article: Article | null; stationKey: string; quantity: number; serialNumber: string; remark: string; book: boolean; from: Stock | null; to: Stock | null };
export function EntryCourierFields({ rows, onChange, disabled, enabled }: { rows: CourierDraft[]; onChange: (rows: CourierDraft[]) => void; disabled: boolean; enabled: boolean }) {
const update = (index: number, patch: Partial<CourierDraft>) => onChange(rows.map((r, i) => i === index ? { ...r, ...patch } : r));
return <details><summary>Kurier-Unteraufträge / Unternehmerverrechnung{rows.length > 0 ? ` (${rows.length})` : ""}</summary>
<p className="form-hint">Für Nacherfassungen im Inland: je Unternehmer entsteht eine Gutschrift mit Gegenbuchung beim ausführenden Kurier. Der Gesamtbetrag ersetzt die einzelne Frachtvergütung. Kundenzuschlag und gegebenenfalls Rabatt werden berücksichtigt; der Servicepreis gilt wie im Bestand je Unternehmer.</p>
{!enabled && <p className="alert">Bitte Nacherfassung und ausführendes Fahrzeug wählen.</p>}
{rows.map((row, index) => <div className="entry-feature-row" key={row.key}>
<Lookup<Vehicle> label={`Unternehmer ${index + 1}`} endpoint="/orders/entry/vehicles" value={row.vehicle} describe={v => `${v.sid} · ${v.name} · ${v.plate}`} disabled={disabled || !enabled} onSelect={vehicle => update(index, { vehicle })} />
<Field label="Verrechnungsbetrag vor Zuschlag (€)"><input type="number" required min="0.01" max="999999.99" step="0.01" disabled={disabled || !enabled} value={row.amount} onChange={e => update(index, { amount: e.target.value })} /></Field>
<button type="button" className="text-button" disabled={disabled} onClick={() => onChange(rows.filter((_, i) => i !== index))}>Unternehmer entfernen</button>
</div>)}
<button type="button" className="text-button" disabled={disabled || !enabled || rows.length >= 20} onClick={() => onChange([...rows, { key: crypto.randomUUID(), vehicle: null, amount: "" }])}>Unternehmer hinzufügen</button>
</details>;
}
export function EntryArticleFields({ rows, onChange, stops, payerId, canBook, disabled, allowBooking }: { rows: ArticleDraft[]; onChange: (rows: ArticleDraft[]) => void; stops: { key: string; name: string }[]; payerId?: number; canBook: boolean; disabled: boolean; allowBooking: boolean }) {
const update = (index: number, patch: Partial<ArticleDraft>) => onChange(rows.map((r, i) => i === index ? { ...r, ...patch } : r));
const outside: Stock = { id: 0, label: "Außerhalb des Lagers", available: 0 };
return <details><summary>Artikel und Lager{rows.length > 0 ? ` (${rows.length})` : ""}</summary>
<p className="form-hint">Artikel werden der gewählten Station zugeordnet. Eine ausgewählte Lagerbuchung erfolgt sofort beim Speichern, auch bei Vorbestellungen. Artikelwerte ändern den Transportpreis nicht. Freie Positionen benötigen keinen Artikelstamm und können keine Lagerbewegung auslösen.</p>
{rows.map((row, index) => <div className="entry-feature-row" key={row.key}>
{!row.freeArticle && <Lookup<Article> label={`Artikel ${index + 1}`} endpoint="/orders/entry/articles" value={row.article} describe={a => `${a.number} · ${a.name}${a.serialRequired ? " · Seriennummer erforderlich" : ""}`} disabled={disabled} onSelect={article => update(index, { article, from: null, to: null, serialNumber: "" })} />}
{row.freeArticle && <div className="form-grid cols-3">
<Field label={`Freier Artikel ${index + 1} *`} wide><input required maxLength={30} disabled={disabled} value={row.freeArticle.name} onChange={e => update(index, { freeArticle: { ...row.freeArticle!, name: e.target.value } })} /></Field>
<Field label="Gesamtgewicht (kg)"><input type="number" required min="0" max="999999.999" step="0.001" disabled={disabled} value={row.freeArticle.totalWeight} onChange={e => update(index, { freeArticle: { ...row.freeArticle!, totalWeight: Number(e.target.value) } })} /></Field>
<Field label="Packstücke"><input type="number" required min="0" max="127" step="1" disabled={disabled} value={row.freeArticle.packingPieces} onChange={e => update(index, { freeArticle: { ...row.freeArticle!, packingPieces: Number(e.target.value) } })} /></Field>
<Field label="Artikelwert (€)"><input type="number" required min="0" max="999999.99" step="0.01" disabled={disabled} value={row.freeArticle.price} onChange={e => update(index, { freeArticle: { ...row.freeArticle!, price: Number(e.target.value) } })} /></Field>
</div>}
<div className="form-grid cols-3">
<Field label="Zugehörige Station"><select required disabled={disabled} value={row.stationKey} onChange={e => update(index, { stationKey: e.target.value })}><option value="">Station wählen</option>{stops.map((s, i) => <option value={s.key} key={s.key}>{i + 1} · {s.name || "Station"}</option>)}</select></Field>
<Field label="Stückzahl"><input required type="number" min="1" max={row.article?.serialRequired || row.serialNumber ? 1 : 32767} step="1" value={row.quantity} disabled={disabled} onChange={e => update(index, { quantity: Number(e.target.value) })} /></Field>
<Field label={`Seriennummer${row.article?.serialRequired ? " *" : ""}`}><input required={row.article?.serialRequired} maxLength={50} value={row.serialNumber} disabled={disabled} onChange={e => update(index, { serialNumber: e.target.value })} /></Field>
<Field label="Artikelhinweis" wide><input maxLength={255} value={row.remark} disabled={disabled} onChange={e => update(index, { remark: e.target.value })} /></Field>
</div>
{canBook && !row.freeArticle && <label className="entry-checkbox"><input type="checkbox" checked={row.book} disabled={disabled || !row.article || !payerId || !allowBooking && !row.book} onChange={e => update(index, { book: e.target.checked })} />Lagerbewegung beim Speichern buchen</label>}
{row.book && row.article && payerId && <div className="form-grid">
{(["from", "to"] as const).map(side => <div key={side}>
<Lookup<Stock> label={side === "from" ? "Quelllager" : "Ziellager"} endpoint={`/orders/entry/stocks?payerId=${payerId}&articleId=${row.article!.id}`} value={row[side]} describe={s => `${s.label}${s.id > 0 ? ` · Bestand ${s.available}` : ""}`} disabled={disabled} onSelect={stock => update(index, { [side]: stock })} />
<button type="button" className="text-button" disabled={disabled} onClick={() => update(index, { [side]: outside })}>{side === "from" ? "Wareneingang von außerhalb" : "Warenausgang nach außerhalb"}</button>
</div>)}
</div>}
{row.book && !allowBooking && <p className="alert">Lagerbuchung ist bei Angeboten und Daueraufträgen nicht möglich. Bitte die Buchung abwählen.</p>}
<button type="button" className="text-button" disabled={disabled} onClick={() => onChange(rows.filter((_, i) => i !== index))}>Artikelposition entfernen</button>
</div>)}
<button type="button" className="text-button" disabled={disabled || rows.length >= 100} onClick={() => onChange([...rows, { key: crypto.randomUUID(), article: null, stationKey: stops[0]?.key ?? "", quantity: 1, serialNumber: "", remark: "", book: false, from: null, to: null }])}>Artikel hinzufügen</button>
<button type="button" className="text-button" disabled={disabled || rows.length >= 100} onClick={() => onChange([...rows, { key: crypto.randomUUID(), article: null, freeArticle: { name: "", totalWeight: 0, packingPieces: 0, price: 0 }, stationKey: stops[0]?.key ?? "", quantity: 1, serialNumber: "", remark: "", book: false, from: null, to: null }])}>Freie Artikelposition hinzufügen</button>
</details>;
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useEntryApi } from "./EntryScope";
type Address = { street: string; houseNumber: string; postalCode: string; city: string; country: string };
type MapAddress = Address & { warning: string };
export function OrderMapPicker({ url, station, disabled, onChange, onOpenChange }: { url: string; station: Address & { name: string }; disabled: boolean; onChange: (address: Partial<Address>) => void; onOpenChange: (open: boolean) => void }) {
const { post } = useEntryApi();
const [open, setOpen] = useState(false), [src, setSrc] = useState(""), [error, setError] = useState(""), [busy, setBusy] = useState(false);
const [selection, setSelection] = useState<MapAddress | null>(null);
const frame = useRef<HTMLIFrameElement>(null), generation = useRef(0), openCallback = useRef(onOpenChange);
useEffect(() => { openCallback.current = onOpenChange; }, [onOpenChange]);
useEffect(() => () => { generation.current++; openCallback.current(false); }, []);
const close = () => { generation.current++; setOpen(false); setBusy(false); setSelection(null); onOpenChange(false); };
async function show() {
setError(""); setSrc(""); setSelection(null); setOpen(true); onOpenChange(true); setBusy(true);
const current = ++generation.current;
const target = new URL(url);
try {
if (station.name && station.street && station.houseNumber && station.postalCode && station.city && station.country) {
const point = await post<{ latitude: number; longitude: number }>("/orders/entry/map/locate", { ...station, type: "BOTH" });
target.searchParams.set("lat", String(point.latitude)); target.searchParams.set("lng", String(point.longitude));
}
} catch (e) { if (current === generation.current) setError(`Die Startadresse konnte nicht zentriert werden. Sie können den Ort in der Karte suchen. ${(e as Error).message}`); }
if (current === generation.current) { setSrc(target.href); setBusy(false); }
}
useEffect(() => {
if (!open || !src) return;
const receive = async (event: MessageEvent) => {
if (event.origin !== new URL(url).origin || event.source !== frame.current?.contentWindow || !event.data || disabled) return;
if (event.data.type === "geocords-closed") { close(); return; }
if (event.data.type !== "geocords-confirmed") return;
const { lat, lng } = event.data;
if (![lat, lng].every(v => (typeof v === "number" || typeof v === "string" && v.trim() !== "") && Number.isFinite(Number(v))) || Math.abs(Number(lat)) > 90 || Math.abs(Number(lng)) > 180) { setError("Die Karte hat ungültige Koordinaten geliefert."); return; }
const current = ++generation.current; setBusy(true); setError(""); setSelection(null);
try { const result = await post<MapAddress>("/orders/entry/map/address", { latitude: Number(lat), longitude: Number(lng) }); if (current === generation.current) setSelection(result); }
catch (e) { if (current === generation.current) setError((e as Error).message); }
finally { if (current === generation.current) setBusy(false); }
};
window.addEventListener("message", receive); return () => window.removeEventListener("message", receive);
}, [open, src, url, disabled]);
return <div className="entry-map-picker">
<button type="button" className="text-button" disabled={disabled || !url} onClick={() => open ? close() : void show()}>{open ? "Kartenauswahl schließen" : "Ort in Karte wählen"}</button>
{!url && <p className="form-hint">Der Kartendienst ist noch nicht hinterlegt (URL_GEOCORDS).</p>}
{open && <div className="entry-map-panel">
{src && <iframe ref={frame} title="Ort in Karte wählen" className="entry-map-frame" src={src} allow="storage-access" />}
{busy && <p role="status">Kartenadresse wird ermittelt …</p>}
{error && <p className="alert error" role="alert">{error}</p>}
{selection && <><p>{selection.street} · {selection.houseNumber} · {selection.postalCode} {selection.city} {selection.country}</p>
{selection.warning && <p className="alert" role="status">{selection.warning} Vorhandene Adressfelder bleiben erhalten, soweit der Dienst keine neuen Werte liefert.</p>}
<button type="button" className="button" disabled={busy || disabled} onClick={() => { const { warning: _warning, ...address } = selection; onChange(Object.fromEntries(Object.entries(address).filter(([, value]) => value !== ""))); close(); }}>Kartenpunkt in Station übernehmen</button></>}
</div>}
</div>;
}
+37
View File
@@ -0,0 +1,37 @@
import {Lookup} from "./EntryLookup";
import {useEffect,useRef,useState} from "react";
import {Field} from "./FormElements";
import {post,put,request} from "./api";
import {ErrorMessage,uncertainWrite} from "./BusinessForms";
type Pricing={mode:string;fixedPrice:number|null;servicePrice:number|null;courierPrice:number|null;courierServicePrice:number|null;markup:number|null;courierMarkup:number|null;applyFixedDiscount:boolean;taxId:number};
type Input={pricing:Pricing;vehicleTypeId:number;services:{serviceId:number;typeId:number;quantity:number}[];costSplit:boolean;confirmCourierLimit:boolean;courierShares:{vehicleId:number;amount:number}[];childrenRevision:string|null;pairIds?:number[]};
type Options={children?:{positive:number;negative:number;sid:string}[];revision:string;input:Input;options:{vehicleTypes:{id:number;label:string}[];taxes:{id:number;label:string;rate:number}[];editCourierPrice:boolean;overrideCourierLimit:boolean};services:{serviceId:number;typeId:number;service:string;type:string}[];paymentTerm:{label:string;source:string}};
type Preview={pairChanges?:{action:string;positive:number;negative:number}[];fingerprint:string;previousNet:number;previousCourierNet:number;quote:{net:number;gross:number;courierNet:number;courierLimitExceeded:boolean;components:{name:string;type:string;net:number}[];costAllocations:{costCenterId:number;name:string;net:number}[];courierAllocations:{sid:string;net:number}[]}};
const euro=(v:number)=>new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(v);
export function OrderRecalculation({id,revision,disabled,onSaved,onDirtyChange,onBusyChange}:{id:number;revision:string;disabled:boolean;onSaved:()=>void;onDirtyChange:(value:boolean)=>void;onBusyChange:(value:boolean)=>void}) {
const [data,setData]=useState<Options|null>(null),[input,setInput]=useState<Input|null>(null),[preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
const [chosenVehicles,setChosenVehicles]=useState<Record<number,{id:number;sid:string;name:string}|null>>({});
const pair=(index:number)=>data?.children?.find(p=>p.positive===input?.pairIds?.[index])||(input?.pairIds?undefined:data?.children?.[index]);
const pending=useRef<object|null>(null),callback=useRef(onDirtyChange);callback.current=onDirtyChange;
const dirty=!!data&&!!input&&JSON.stringify(input)!==JSON.stringify(data.input);
useEffect(()=>{onBusyChange(busy||uncertain);return()=>onBusyChange(false);},[busy,uncertain,onBusyChange]);
useEffect(()=>{callback.current(dirty||uncertain||busy);return()=>callback.current(false);},[dirty,uncertain,busy]);
useEffect(()=>{let live=true;setPreview(null);setError("");request<Options>(`/orders/${id}/calculation`).then(d=>{if(live){setData(d);setInput(d.input);setChosenVehicles({});}}).catch(e=>{if(live)setError(e.message);});return()=>{live=false;};},[id,revision]);
function change(patch:Partial<Input>){pending.current=null;setPreview(null);setNotice("");setInput(v=>v?{...v,...patch}:v);}
function price(key:keyof Pricing,value:Pricing[keyof Pricing]){if(input)change({pricing:{...input.pricing,[key]:value}});}
async function calculate(){if(!data||!input)return;setBusy(true);setError("");setNotice("");try{setPreview(await post<Preview>(`/orders/${id}/calculation`,{revision:data.revision,input}));}catch(e){setPreview(null);setError((e as Error).message);}finally{setBusy(false);}}
async function save(){if(!data||!input||!preview)return;setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),revision:data.revision,input,fingerprint:preview.fingerprint};try{await put(`/orders/${id}/calculation`,pending.current);pending.current=null;setUncertain(false);setPreview(null);setNotice("Kalkulation gespeichert. Sonstige Nachbearbeitungsgründe bleiben erhalten.");setData({...data,input});onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?" Bitte denselben Speichervorgang erneut prüfen.":""));}finally{setBusy(false);}}
return <section className="business-section"><h3>Preis neu kalkulieren</h3><p>Berechnung für die gespeicherten Stationen und die Auftragszeit. Änderungen am Auftrag zuerst speichern.</p><ErrorMessage message={error}/>{notice&&<p role="status" className="admin-success">{notice}</p>}{data&&input&&<><fieldset className="business-fieldset" disabled={disabled||busy||uncertain}><div className="form-grid cols-3">
<Field label="Berechnungsart"><select value={input.pricing.mode} onChange={e=>change({pricing:{...input.pricing,mode:e.target.value},services:e.target.value==="SERVICES"?input.services:[]})}><option value="TARIFF">PLZ-/Bereichstarif</option><option value="FIXED">Festpreis mit Zuschlägen</option><option value="KILOMETER">Kilometerpreis</option><option value="ZONE">PZM-Zonenpreis</option><option value="SERVICES">Leistungskalkulation</option></select></Field>
<Field label="Fahrzeugtyp für Kalkulation"><select value={input.vehicleTypeId} onChange={e=>change({vehicleTypeId:Number(e.target.value)})}>{data.options.vehicleTypes.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
<Field label="Steuer für Kalkulation"><select value={input.pricing.taxId} onChange={e=>price("taxId",Number(e.target.value))}><option value={0}>Bitte wählen</option>{data.options.taxes.map(v=><option key={v.id} value={v.id}>{v.label} · {v.rate} %</option>)}</select></Field>
{([...(input.pricing.mode==="FIXED"?[["fixedPrice","Basis-Festpreis (€)"]]:[]),["servicePrice","Servicepreis (€)"],["markup","Kundenzuschlag (%)"],...(data.options.editCourierPrice&&!input.courierShares?.length?[["courierPrice","Basis-Frachtvergütung (€)"],["courierServicePrice","Service-Frachtvergütung (€)"],["courierMarkup","Kurierzuschlag (%)"]]:[])] as [keyof Pricing,string][]).map(([key,label])=><Field key={key} label={label}><input type="number" step="0.01" min={key.includes("arkup")?-100:0} max={key.includes("arkup")?100:999999.99} value={input.pricing[key]===null?"":String(input.pricing[key])} placeholder="Automatisch" onChange={e=>price(key,e.target.value===""?null:Number(e.target.value))}/></Field>)}
</div><label className="checkbox-label"><input type="checkbox" checked={input.pricing.applyFixedDiscount} onChange={e=>price("applyFixedDiscount",e.target.checked)}/>Kundenrabatt bei Fest-/Servicepreis berücksichtigen</label><label className="checkbox-label"><input type="checkbox" checked={input.costSplit} onChange={e=>change({costSplit:e.target.checked})}/>Kosten auf interne Stationskostenstellen und Zahler aufteilen</label>
{input.pricing.mode==="SERVICES"&&input.services.map((item,index)=><div className="form-grid cols-3" key={index}><Field label={`Leistung ${index+1}`}><select value={`${item.serviceId}:${item.typeId}`} onChange={e=>{const [serviceId,typeId]=e.target.value.split(":").map(Number);change({services:input.services.map((s,i)=>i===index?{...s,serviceId,typeId}:s)});}}><option value="0:0">Bitte wählen</option>{data.services.map(s=><option key={`${s.serviceId}:${s.typeId}`} value={`${s.serviceId}:${s.typeId}`}>{s.service} · {s.type}</option>)}</select></Field><Field label={`Menge ${index+1}`}><input type="number" min="0.001" max="99999" step="0.001" value={item.quantity} onChange={e=>change({services:input.services.map((s,i)=>i===index?{...s,quantity:Number(e.target.value)}:s)})}/></Field><button type="button" className="button" onClick={()=>change({services:input.services.filter((_,i)=>i!==index)})}>Leistung entfernen</button></div>)}
{data.options.editCourierPrice&&<section className="business-section"><h4>Unternehmerverrechnung</h4><p>Hauptauftrag, Unteraufträge und Gegenbelege werden gemeinsam neu kalkuliert. Bestehende Belegnummern und Zustellnachweise bleiben erhalten. Entfernte Paare erhalten Gegenbuchungen. Bereits abgerechnete Belege verhindern die Änderung.</p>{(input.courierShares||[]).map((share,index)=><div className="form-grid" key={index}><Lookup<{id:number;sid:string;name:string}> label={`Unternehmer für Beleg ${pair(index)?.positive||index+1}`} endpoint="/orders/entry/vehicles" value={index in chosenVehicles?chosenVehicles[index]:share.vehicleId?{id:share.vehicleId,sid:pair(index)?.sid||String(share.vehicleId),name:''}:null} describe={v=>`${v.sid} · ${v.name}`} disabled={disabled||busy||uncertain||!data.options.editCourierPrice} onSelect={v=>{setChosenVehicles(old=>({...old,[index]:v}));change({courierShares:input.courierShares.map((s,i)=>i===index?{...s,vehicleId:v?.id||0}:s)});}}/><Field label={`Unternehmerbetrag ${index+1} vor Zuschlag (€)`}><input type="number" min="0.01" max="999999.99" step="0.01" disabled={!data.options.editCourierPrice} value={share.amount} onChange={e=>change({courierShares:input.courierShares.map((s,i)=>i===index?{...s,amount:Number(e.target.value)}:s)})}/></Field><p>{pair(index)?`Beleg #${pair(index)?.positive} / Gegenbeleg #${pair(index)?.negative}`:'Neues Belegpaar'}</p><button className="button" type="button" onClick={()=>{const next:typeof chosenVehicles={};input.courierShares.forEach((_,i)=>{if(i!==index&&i in chosenVehicles)next[i>index?i-1:i]=chosenVehicles[i];});setChosenVehicles(next);change({courierShares:input.courierShares.filter((_,i)=>i!==index),pairIds:(input.pairIds||data.children?.map(p=>p.positive)||[]).filter((_,i)=>i!==index)});}}>Belegpaar entfernen</button></div>)}<button className="button" type="button" disabled={(input.courierShares?.length||0)>=20} onClick={()=>change({courierShares:[...(input.courierShares||[]),{vehicleId:0,amount:0}],pairIds:[...(input.pairIds||data.children?.map(p=>p.positive)||[]),0],pricing:{...input.pricing,courierPrice:null,courierServicePrice:null,courierMarkup:null}})}>Unternehmerbelegpaar hinzufügen</button></section>}
<div className="admin-row-actions"><button type="button" className="button" disabled={input.pricing.mode!=="SERVICES"||input.services.length>=100} onClick={()=>change({services:[...input.services,{serviceId:0,typeId:0,quantity:1}]})}>Leistung hinzufügen</button><button type="button" className="button primary" onClick={()=>void calculate()}>Preisvorschau berechnen</button><button type="button" className="button" disabled={!dirty} onClick={()=>{setInput(data.input);setChosenVehicles({});setPreview(null);}}>Kalkulationsänderungen verwerfen</button></div>
{data.options.overrideCourierLimit&&<label className="checkbox-label"><input type="checkbox" checked={input.confirmCourierLimit} onChange={e=>change({confirmCourierLimit:e.target.checked})}/>Überschreitung der Frachtvergütungsgrenze bestätigen</label>}</fieldset>
{preview&&<div className="business-section"><h4>Preisvorschau</h4><div className="business-summary"><span>Kundenpreis <strong>{euro(preview.previousNet)} → {euro(preview.quote.net)}</strong></span><span>Frachtvergütung <strong>{euro(preview.previousCourierNet)} → {euro(preview.quote.courierNet)}</strong></span></div><table className="admin-table"><thead><tr><th>Position</th><th>Netto</th></tr></thead><tbody>{preview.quote.components.map((c,i)=><tr key={i}><td>{c.name} {c.type}</td><td>{euro(c.net)}</td></tr>)}</tbody></table>{preview.quote.costAllocations.map(c=><p key={c.costCenterId}>{c.name}: {euro(c.net)}</p>)}{preview.quote.courierAllocations?.map((c,i)=><p key={`child-${i}`}>{c.sid}: Beleg #{pair(i)?.positive||"neu"} {euro(c.net)} / Gegenbeleg #{pair(i)?.negative||"neu"} {euro(-c.net)}</p>)}{preview.pairChanges?.filter(p=>p.action==="REMOVE").map(p=><p key={`remove-${p.positive}`}>Beleg #{p.positive} und Gegenbeleg #{p.negative} aufheben: zwei zusätzliche Gegenbuchungen.</p>)}{preview.quote.courierLimitExceeded&&<p className="alert info">Frachtvergütungsgrenze überschritten.</p>}<button className="button primary" disabled={disabled||busy} onClick={()=>void save()}>{uncertain?"Speichervorgang erneut prüfen":"Kalkulation übernehmen"}</button></div>}
{data.paymentTerm.label&&<p className="form-hint">Zahlungsbedingung: {data.paymentTerm.label} · {data.paymentTerm.source}</p>}</>}</section>;
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { request, post, put } from "./api";
import { BusinessFields, ErrorMessage, uncertainWrite, type Definition, type Values } from "./BusinessForms";
import {OrderCancellation} from "./OrderCancellation";
import {OrderArticles} from "./OrderArticles";
import {OrderRecalculation} from "./OrderRecalculation";
type Station={id:number;completed:boolean;recipient:string;values:Values};
type Workbench={id:number;revision:string;fields:Definition[];stopFields:Definition[];values:Values;stations:Station[];status:number;storno:number;courier:string;customerNet:number;courierNet:number;readOnly:boolean;creditId?:number;canEdit:boolean;canPrice:boolean;prices:Record<string,string|number>[]};
type Vehicle={id:number;sid:string;name?:string;label?:string};
const euro=(v:number)=>new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(v);
export function OrderWorkbench({id,onClose,onSaved}:{id:number;onClose:()=>void;onSaved:()=>void}){
const [data,setData]=useState<Workbench|null>(null),[values,setValues]=useState<Values>({}),[stations,setStations]=useState<Station[]>([]),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[priceCustomer,setPriceCustomer]=useState(""),[priceCourier,setPriceCourier]=useState(""),[priceReason,setPriceReason]=useState(""),[action,setAction]=useState(""),[vehicleQuery,setVehicleQuery]=useState(""),[vehicles,setVehicles]=useState<Vehicle[]>([]),[vehicle,setVehicle]=useState(""),[recipient,setRecipient]=useState(""),[stationId,setStationId]=useState("");
const [articlesOpen,setArticlesOpen]=useState(false),[cancellationOpen,setCancellationOpen]=useState(false);
const [calculationDirty,setCalculationDirty]=useState(false),[calculationBusy,setCalculationBusy]=useState(false);
const [discard,dialog]=useDiscardConfirmation();const pending=useRef<{path:string;body:unknown}|null>(null);const dirty=!!data&&(JSON.stringify(values)!==JSON.stringify(data.values)||JSON.stringify(stations)!==JSON.stringify(data.stations));
async function load(){try{const d=await request<Workbench>(`/orders/${id}/workbench`);setData(d);setValues(d.values);setStations(d.stations);setPriceCustomer(String(d.customerNet));setPriceCourier(String(d.courierNet));}catch(e){setError((e as Error).message);}}
useEffect(()=>{void load();},[id]);
useEffect(()=>{if(action!=="ASSIGN")return;let live=true;const timer=setTimeout(()=>request<Vehicle[]>(`/orders/entry/vehicles?q=${encodeURIComponent(vehicleQuery)}`).then(v=>{if(live)setVehicles(v);}).catch(e=>{if(live)setError(e.message);}),250);return()=>{live=false;clearTimeout(timer);};},[vehicleQuery,action]);
async function close(){if(!busy&&!uncertain&&!calculationBusy&&(!(dirty||calculationDirty||action||priceReason)||await discard("Ungespeicherte Auftragsänderungen verwerfen?")))onClose();}
async function save(e:FormEvent){e.preventDefault();if(!data)return;setBusy(true);setError("");pending.current??={path:`/orders/${id}/workbench`,body:{requestId:crypto.randomUUID(),revision:data.revision,values,stations:stations.map(s=>({id:s.id,values:s.values}))}};try{await put(pending.current.path,pending.current.body);pending.current=null;setUncertain(false);await load();onSaved();setNotice("Auftrag gespeichert. Nach Änderungen an Route oder Auftragszeit den Preis prüfen und die Nachbearbeitung abschließen.");}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?" Speicherausgang unklar. Bitte Vorgang erneut ausführen.":""));}finally{setBusy(false);}}
async function execute(e:FormEvent){e.preventDefault();if(!data)return;setBusy(true);setError("");pending.current??={path:`/orders/${id}/actions`,body:{requestId:crypto.randomUUID(),revision:data.revision,action,vehicleId:vehicle?Number(vehicle):null,stationId:stationId?Number(stationId):null,recipient}};try{const result=await post<{resultId:number}>(pending.current.path,pending.current.body);pending.current=null;setUncertain(false);await load();onSaved();setNotice(result.resultId!==id?`Gegenbuchung #${result.resultId} angelegt.`:"Auftragsaktion gespeichert.");setAction("");}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?" Speicherausgang unklar. Bitte Vorgang erneut ausführen.":""));}finally{setBusy(false);}}
async function savePrice(e:FormEvent){e.preventDefault();if(!data)return;setBusy(true);setError("");pending.current??={path:`/orders/${id}/price`,body:{requestId:crypto.randomUUID(),revision:data.revision,customerNet:Number(priceCustomer),courierNet:Number(priceCourier),reason:priceReason}};try{await put(pending.current.path,pending.current.body);pending.current=null;setUncertain(false);await load();setPriceReason("");onSaved();setNotice("Manueller Fixpreis gespeichert.");}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)pending.current=null;setError((e as Error).message+(retry?" Speicherausgang unklar. Bitte Vorgang erneut ausführen.":""));}finally{setBusy(false);}}
function change(key:string,value:string|number){pending.current=null;setValues(v=>({...v,[key]:value}));}
return <><Modal title={`Auftrag #${id} bearbeiten`} subtitle={data?`Status ${data.storno?"Storniert":["Angeboten","Angenommen","Erledigt"][data.status]||"Disposition"} · Fahrer ${data.courier||"–"}`:"Bestandsauftrag"} onClose={()=>void close()} wide><div className="modal-body"><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}{data?<><div className="business-summary"><span>Kundenpreis <strong>{euro(data.customerNet)}</strong></span><span>Frachtvergütung <strong>{euro(data.courierNet)}</strong></span></div>{data.readOnly&&<p className="alert info">{data.creditId?`Gegenbuchung #${data.creditId} vorhanden. Der Originalauftrag ist für weitere Änderungen gesperrt.`:"Bereits exportiert oder einer Rechnung zugeordnet. Korrekturen erfolgen über eine Gutschrift."}</p>}
<form onSubmit={save}><fieldset className="business-fieldset" disabled={busy||uncertain||calculationDirty||data.readOnly||!!data.storno||!data.canEdit}><BusinessFields fields={data.fields} values={values} onChange={change}/><section className="business-section"><h3>Stationen</h3>{stations.map((s,index)=><details className="business-station" key={s.id||`new-${index}`}><summary>{index+1}. {String(s.values.tr_comp||"Neue Station")} · {String(s.values.ad_city||"")}{s.completed?` · Erledigt (${s.recipient||"Nachweis vorhanden"})`:" · Offen"}</summary><BusinessFields fields={data.stopFields} values={s.values} onChange={(key,value)=>{pending.current=null;setStations(v=>v.map((t,i)=>i===index?{...t,values:{...t.values,[key]:value}}:t));}}/><div className="admin-row-actions"><button type="button" className="button" disabled={index===0} onClick={()=>setStations(v=>{const a=[...v];[a[index-1],a[index]]=[a[index],a[index-1]];return a;})}>Nach oben</button><button type="button" className="button" disabled={index===stations.length-1} onClick={()=>setStations(v=>{const a=[...v];[a[index+1],a[index]]=[a[index],a[index+1]];return a;})}>Nach unten</button><button type="button" className="button danger-quiet" disabled={s.completed} onClick={()=>setStations(v=>v.filter((_,i)=>i!==index))}>Entfernen</button></div></details>)}<button type="button" className="button" onClick={()=>setStations(v=>[...v,{id:0,completed:false,recipient:"",values:Object.fromEntries(data.stopFields.map(f=>[f.key,f.key==="ad_country"?"DE":f.key==="tr_ware_from_to"?1:""]))}])}>Station hinzufügen</button></section><button className="button primary" disabled={!dirty}>Änderungen speichern</button></fieldset></form>
{uncertain&&<button className="button primary" disabled={busy} onClick={()=>{const event={preventDefault(){}} as FormEvent;void (pending.current?.path.endsWith("/actions")?execute(event):pending.current?.path.endsWith("/price")?savePrice(event):save(event));}}>Speichervorgang erneut prüfen</button>}
{data.canEdit&&!data.readOnly&&!data.storno&&<button className="button" disabled={dirty||busy||uncertain||calculationDirty||calculationBusy} onClick={()=>setArticlesOpen(true)}>Artikel und Lager bearbeiten</button>}
{data.canEdit&&!data.storno&&!data.creditId&&<button className="button" disabled={dirty||busy||uncertain||calculationDirty||calculationBusy} onClick={()=>setCancellationOpen(true)}>Storno / Gutschrift vorbereiten</button>}
<section className="business-section"><h3>Preispositionen</h3><table className="admin-table"><thead><tr><th>Kostenstelle</th><th>Leistung</th><th>Preis</th><th>Rabatt</th></tr></thead><tbody>{data.prices.map((p,i)=><tr key={i}><td>{p.csc_id}</td><td>{p.trs_srv_name} {p.trs_srvt_name}</td><td>{euro(Number(p.trs_price))}</td><td>{p.trs_discount} %</td></tr>)}</tbody></table></section>
{data.canEdit&&!data.readOnly&&!data.storno&&<OrderRecalculation id={id} revision={data.revision} disabled={dirty||busy||uncertain} onSaved={()=>{void load();onSaved();}} onDirtyChange={setCalculationDirty} onBusyChange={setCalculationBusy}/>}
{data.canEdit&&data.canPrice&&!data.readOnly&&!data.storno&&<form className="business-section" onSubmit={savePrice}><h3>Preisberichtigung</h3><p>Die bisherigen Preispositionen werden durch einen manuellen Netto-Fixpreis ersetzt. Zuschläge und Rabatte müssen darin enthalten sein. Bei Kostensplit wird der Betrag gleichmäßig auf die bisherigen Kostenstellen verteilt; Maut bleibt zusätzlich erhalten.</p><fieldset className="business-fieldset" disabled={busy||uncertain||dirty||calculationDirty}><div className="form-grid cols-3"><Field label="Kundenpreis netto (€)"><input required type="number" min="0" max="999999.99" step="0.01" value={priceCustomer} onChange={e=>setPriceCustomer(e.target.value)}/></Field><Field label="Frachtvergütung netto (€)"><input required type="number" min="0" max="999999.99" step="0.01" value={priceCourier} onChange={e=>setPriceCourier(e.target.value)}/></Field><Field label="Begründung der Preisberichtigung"><input required maxLength={200} value={priceReason} onChange={e=>setPriceReason(e.target.value)}/></Field></div><button className="button primary">Fixpreis übernehmen</button></fieldset></form>}
{data.canEdit&&!data.storno&&!data.creditId&&<section className="business-section"><h3>Disposition und Abschluss</h3><form onSubmit={execute} onChange={()=>{pending.current=null;}}><fieldset className="business-fieldset" disabled={busy||uncertain||dirty||calculationDirty}><div className="form-grid"><Field label="Aktion"><select value={action} onChange={e=>setAction(e.target.value)} required><option value="">Bitte wählen</option>{!data.readOnly&&data.status!==2&&<><option value="ASSIGN">Fahrzeug zuweisen / neu disponieren</option><option value="RECALL">Zurück in die Disposition</option>{data.status===0&&<option value="ACCEPT">Annahme buchen</option>}{data.status===1&&<><option value="COMPLETE_STOP">Einzelne Station abschließen</option><option value="COMPLETE">Auftrag abschließen</option></>}</>}</select></Field>
{action==="ASSIGN"&&<><Field label="Fahrzeug suchen"><input value={vehicleQuery} onChange={e=>setVehicleQuery(e.target.value)}/></Field><Field label="Fahrzeug"><select value={vehicle} required onChange={e=>setVehicle(e.target.value)}><option value="">Bitte wählen</option>{vehicles.map(v=><option key={v.id} value={v.id}>{v.sid} · {v.name||v.label||""}</option>)}</select></Field></>}
{action==="COMPLETE_STOP"&&<Field label="Offene Station"><select required value={stationId} onChange={e=>setStationId(e.target.value)}><option value="">Bitte wählen</option>{data.stations.filter(s=>!s.completed).map((s)=><option key={s.id} value={s.id}>{String(s.values.tr_comp)}</option>)}</select></Field>}{action.startsWith("COMPLETE")&&<Field label="Empfänger / Erledigungsnachweis"><input required maxLength={50} value={recipient} onChange={e=>setRecipient(e.target.value)}/></Field>}
</div>{dirty&&<p className="form-hint">Änderungen vor einer Auftragsaktion speichern.</p>}<button className="button primary" disabled={!action||busy||dirty}>Aktion ausführen</button></fieldset></form></section>}</>:!error&&<p>Auftrag wird geladen …</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain||calculationBusy} onClick={()=>void close()}>Schließen</button></div></Modal>{articlesOpen&&<OrderArticles id={id} onClose={()=>setArticlesOpen(false)} onSaved={()=>{void load();onSaved();}}/>}{cancellationOpen&&<OrderCancellation id={id} onClose={()=>setCancellationOpen(false)} onSaved={()=>{void load();onSaved();}}/>}{dialog}</>;
}
+12
View File
@@ -0,0 +1,12 @@
export type PhotoWorkflow = {number:number;name:string;defaultWorkflow:boolean;min:number;max:number;stationScope:string;positions:string[]};
export type PhotoSet = {number:number;name:string;defaultSet:boolean;workflows:PhotoWorkflow[]};
export type PhotoOptions = {configured:boolean;sets:PhotoSet[]};
export function photoFlows(set:PhotoSet|null,type:"PICKUP"|"DELIVERY"|"BOTH") {
// Match PHP photoScopeMatches on stored tr_ware_from_to (PICKUP=2, DELIVERY=1).
return (set?.workflows??[]).filter(f=>type==="BOTH" || f.stationScope==="ALL" || (["PICKUP_ONLY","EXCEPT_DELIVERY"].includes(f.stationScope)?type==="DELIVERY":type==="PICKUP"));
}
export function defaultPhoto(flows:PhotoWorkflow[],index:number,count:number) {
const position=index===count-1?"LAST":index===0?"FIRST":"MIDDLE";
return flows.find(f=>f.positions.includes(position))??flows.find(f=>f.defaultWorkflow);
}
export const photoLabel=(f:PhotoWorkflow)=>`${f.name} · ${f.min===f.max?f.min:`${f.min}–${f.max}`} Fotos`;
+60
View File
@@ -0,0 +1,60 @@
import { createElement, Fragment, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { FileText, LoaderCircle, Printer, Save } from 'lucide-react';
import { request, put } from './api';
import { Field, Modal, useDiscardConfirmation } from './FormElements';
type Definition = { id: number; label: string; type: string; editable: boolean };
type Detail = { id: number; category: number; title: string; canEdit: boolean; values: Record<string,string>; fields: Definition[]; revision: string; schemaRevision: string; reportRevision: string; html: string; layout: string };
type History = {fieldId: number; value: string; time: string};
export function ReportForms({kind,id,onClose,onDirty}:{kind:'CUSTOMER'|'COURIER';id:number;onClose:()=>void;onDirty:(v:boolean)=>void}) {
const base=`/administration/reports/${kind}/${id}/forms`;
const [options,setOptions]=useState<{id:number;label:string}[]>([]),[category,setCategory]=useState(0),[detail,setDetail]=useState<Detail|null>(null),[values,setValues]=useState<Record<string,string>>({});
const [busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[mode,setMode]=useState<'view'|'edit'>('view'),[history,setHistory]=useState<History[]|null>(null);
const [discard,dialog]=useDiscardConfirmation();const frame=useRef<HTMLIFrameElement>(null);
const changed=!!detail&&detail.fields.some(f=>f.editable&&(values[f.id]||'')!==(detail.values[f.id]||''));
useEffect(()=>{onDirty(changed||busy);return()=>onDirty(false);},[changed,busy,onDirty]);
useEffect(()=>{let active=true;setBusy(true);request<typeof options>(base).then(o=>{if(active){setOptions(o);if(o.length)setCategory(o[0].id);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[base]);
useEffect(()=>{if(!category)return;let active=true;setBusy(true);setDetail(null);setError('');setHistory(null);request<Detail>(`${base}/${category}`).then(d=>{if(active){setDetail(d);setValues(d.values);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[base,category]);
async function close(){if(!busy&&(!changed||await discard('Ungespeicherte Formularänderungen verwerfen?')))onClose();}
async function choose(value:number){if(!busy&&(!changed||await discard('Ungespeicherte Formularänderungen verwerfen?'))){setCategory(value);setMode('view');setNotice('');}}
async function save(){if(!detail)return;setBusy(true);setError('');try{await put(`${base}/${category}`,{revision:detail.revision,schemaRevision:detail.schemaRevision,reportRevision:detail.reportRevision,values});const d=await request<Detail>(`${base}/${category}`);setDetail(d);setValues(d.values);setNotice('Berichtsformular gespeichert.');setMode('view');}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const documentHtml=detail?`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><title>Berichtsformular ${id}</title></head><body>${detail.html}</body></html>`:'';
return <><Modal title={`Formulare zu Bericht ${id}`} onClose={()=>void close()} wide><div className="admin-dialog-body">
<div className="admin-toolbar"><Field label="Berichtsformular"><select value={category} disabled={busy} onChange={e=>void choose(Number(e.target.value))}>{!options.length&&<option value={0}>Keine Formulare eingerichtet</option>}{options.map(o=><option key={o.id} value={o.id}>{o.label}</option>)}</select></Field>{detail?.canEdit&&<button className="button" disabled={busy||changed} onClick={()=>setMode(mode==='edit'?'view':'edit')}>{mode==='edit'?'Vorschau':'Formular bearbeiten'}</button>}{detail&&mode==='view'&&<button className="button" disabled={busy} onClick={()=>frame.current?.contentWindow?.print()}><Printer size={16}/>Drucken / PDF</button>}</div>
{error&&<div role="alert" className="alert error">{error}</div>}{notice&&<p role="status" className="admin-success">{notice}</p>}{busy&&<LoaderCircle className="spin"/>}
{detail&&<><h3>{detail.title}</h3>{mode==='edit'?<fieldset disabled={busy}><ReportFormLayout html={detail.layout} fields={detail.fields} values={values} onChange={(key,v)=>setValues(old=>({...old,[key]:v}))} lookup={`${base}/vehicles`}/><div className="admin-row-actions"><button className="button" disabled={!changed} onClick={async()=>{if(await discard('Formulareingaben zurücksetzen?'))setValues(detail.values);}}>Zurücksetzen</button><button className="button primary" disabled={!changed} onClick={()=>void save()}><Save size={16}/>Formular speichern</button></div></fieldset>:<iframe ref={frame} title={`${detail.title} – Vorschau`} className="newsletter-preview" sandbox="allow-same-origin allow-modals" srcDoc={documentHtml}/>}
<div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>void request<History[]>(`${base}/${category}/history`).then(setHistory).catch(e=>setError(e.message))}><FileText size={16}/>Feldhistorie</button><button className="button" disabled={busy} onClick={()=>void close()}>Schließen</button></div>{history&&<section><h3>Feldhistorie</h3>{!history.length?<p>Keine historischen Werte vorhanden.</p>:history.map((h,i)=><p className="message-text" key={i}><strong>{detail.fields.find(f=>f.id===h.fieldId)?.label} · {new Date(h.time).toLocaleString('de-DE')}</strong><br/>{h.value}</p>)}</section>}</>}
</div></Modal>{dialog}</>;
}
function ReportFormLayout({html,fields,values,onChange,lookup}:{html:string;fields:Definition[];values:Record<string,string>;onChange:(id:number,value:string)=>void;lookup:string}) {
// The backend sanitizes templates. Rebuild their allowed layout with React controls;
// no PHP, script, inline event handler, or template-supplied form is executed.
const doc=useMemo(()=>new DOMParser().parseFromString(html,'text/html'),[html]);
const scope=`report-layout-${useId().replace(/[^a-zA-Z0-9_-]/g,'')}`;
// Preserve the sanitized template's class rules, scoped to this editor so they
// cannot change the application or another open form.
const css=Array.from(doc.querySelectorAll('style')).flatMap(style=>Array.from((style.textContent||'').matchAll(/([^{}]+)\{([^{}]*)\}/g))).map(([,selectors,body])=>{
const scoped=selectors.split(',').map(s=>s.trim()).filter(s=>s&&/^[a-zA-Z0-9_.# :>\s-]+$/.test(s)).map(s=>/^(?:html\s+)?body(?=[\s.>#:])|^body$/.test(s)?s.replace(/^(?:html\s+)?body/,`#${scope}`):`#${scope} ${s}`);
return scoped.length?`${scoped.join(',')}{${body}}`:'';
}).join('');
const used=new Set<number>();
function render(node:Node,key:string):ReactNode {
if(node.nodeType===Node.TEXT_NODE){const text=node.textContent||'';const pieces=text.split(/(NGFORMFIELD\d+END)/);return <Fragment key={key}>{pieces.map((part,i)=>{const match=part.match(/^NGFORMFIELD(\d+)END$/),field=match?fields.find(f=>f.id===Number(match[1])):null;if(!field)return part;used.add(field.id);return <ReportInput key={`${key}-${i}`} field={field} value={values[field.id]||''} onChange={v=>onChange(field.id,v)} lookup={lookup}/>;})}</Fragment>;}
if(!(node instanceof Element))return null;const tag=node.tagName.toLowerCase();if(!['div','span','p','b','strong','i','em','u','h1','h2','h3','h4','table','thead','tbody','tfoot','tr','th','td','br','hr','img','font','ul','ol','li','blockquote','a'].includes(tag))return null;
const props:Record<string,unknown>={key};if(node.hasAttribute('class'))props.className=node.getAttribute('class');for(const attr of ['colspan','rowspan','width','height','align','valign','src','alt'])if(node.hasAttribute(attr))props[attr==='colspan'?'colSpan':attr==='rowspan'?'rowSpan':attr==='valign'?'vAlign':attr]=node.getAttribute(attr);
if(node.hasAttribute('style')){const style:Record<string,string>={};for(const part of node.getAttribute('style')!.split(';')){const [name,...value]=part.split(':');if(name&&value.length)style[name.trim().replace(/-([a-z])/g,(_,letter:string)=>letter.toUpperCase())]=value.join(':').trim();}props.style=style as CSSProperties;}
const outputTag=tag==='a'||tag==='font'?'span':tag;
return createElement(outputTag,props,...Array.from(node.childNodes).map((n,i)=>render(n,`${key}-${i}`)));
}
const layout=Array.from(doc.body.childNodes).map((n,i)=>render(n,String(i)));
return <div id={scope} className="report-form-layout"><style>{css}</style>{layout}{fields.filter(f=>f.editable&&!used.has(f.id)).length>0&&<details><summary>Weitere konfigurierte Felder</summary>{fields.filter(f=>f.editable&&!used.has(f.id)).map(f=><Field key={f.id} label={f.label}><ReportInput field={f} value={values[f.id]||''} onChange={v=>onChange(f.id,v)} lookup={lookup}/></Field>)}</details>}</div>;
}
function ReportInput({field,value,onChange,lookup}:{field:Definition;value:string;onChange:(v:string)=>void;lookup:string}) {
const [items,setItems]=useState<{id:number;value:string;label:string}[]>([]),[error,setError]=useState('');
const vehicle=field.type==='select:crvh:crvh_sid';
useEffect(()=>{if(!vehicle)return;let active=true;const timer=setTimeout(()=>request<typeof items>(lookup+'?q='+encodeURIComponent(value)).then(v=>{if(active){setItems(v);setError('');}}).catch(e=>{if(active)setError(e.message);}),250);return()=>{active=false;clearTimeout(timer);};},[lookup,value,vehicle]);
if(field.type==='checkbox')return <input aria-label={field.label} type="checkbox" checked={value==='1'} onChange={e=>onChange(e.target.checked?'1':'')}/>;
if(field.type==='text')return <textarea aria-label={field.label} rows={3} value={value} onChange={e=>onChange(e.target.value)}/>;
if(['date','birthdate'].includes(field.type)){const parts=value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);return <input aria-label={field.label} type="date" value={parts?`${parts[3]}-${parts[2]}-${parts[1]}`:''} onChange={e=>onChange(e.target.value.split('-').reverse().join('.'))}/>;}
return <span className="report-inline-field"><input aria-label={field.label} type={field.type==='time'?'time':'text'} value={value} onChange={e=>onChange(e.target.value)} list={vehicle?`report-vehicles-${field.id}`:undefined}/>{vehicle&&<datalist id={`report-vehicles-${field.id}`}>{items.map(i=><option key={i.id} value={i.value}>{i.label}</option>)}</datalist>}{error&&<small role="alert">{error}</small>}</span>;
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState, type FormEvent } from "react";
import { Download, FileText, LoaderCircle, LockKeyhole, Pencil, Plus, Save, Trash2 } from "lucide-react";
import { request, post, put } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { Lookup } from "./EntryLookup";
import { ReportForms } from "./ReportForms";
import { AppointmentEditor, type AppointmentSeed } from "./AppointmentForm";
type Option = { id: number; label: string };
type ObjectOption = Option & { headquarters?: string };
type Kind = "CUSTOMER" | "COURIER";
type Options = { types: Option[]; headquarters: Option[]; currentHeadquarters: number; from: string; to: string; miscType: string; completeAppointments: boolean; canCreateAppointments: boolean };
type Query = { from: string; to: string; headquarters: number[]; objectId: number | null; type: number | null; search: string; page: number; sort: string; descending: boolean; splitType: boolean; splitCustomer: boolean };
type Report = { id: number; headquarters: string; objectId: number; objectLabel: string; author: string; phone: string; createdAt: string | null; text: string; type: number; typeLabel: string; confidential: boolean; canEdit: boolean; canDelete: boolean; revision: string };
type Result = { items: Report[]; total: number; page: number; pageSize: number };
type Statistic = { rows: { headquarters: string; author: string; type: string; customerType: string; total: number }[]; total: number };
type Saved = { id: number; warning: string; objectId?: number; objectLabel?: string };
const date = (value: string | null) => value ? new Date(value).toLocaleString("de-DE") : "Kein Datum";
const csvCell = (value: unknown) => {
let text = String(value ?? "");
if (/^[\s]*[=+\-@\t\r]/.test(text)) text = "'" + text;
return `"${text.replaceAll('"', '""')}"`;
};
export function Reports({ kind, refresh, onDirtyChange, initialObject }: { kind: Kind; refresh: number; onDirtyChange: (dirty: boolean) => void; initialObject?: ObjectOption }) {
const base = `/administration/reports/${kind}`;
const objectName = kind === "CUSTOMER" ? "Kunde" : "Transporteur";
const [formReport,setFormReport]=useState<number|null>(null);
const [appointmentSeed, setAppointmentSeed] = useState<AppointmentSeed | null>(null);
const [options, setOptions] = useState<Options | null>(null), [filter, setFilter] = useState<Query | null>(null);
const [selected, setSelected] = useState<ObjectOption | null>(null), [applied, setApplied] = useState<Query | null>(null);
const [data, setData] = useState<Result | null>(null), [stats, setStats] = useState<Statistic | null>(null), [statsQuery, setStatsQuery] = useState<Query | null>(null);
const [editor, setEditor] = useState<Report | "new" | null>(null), [removing, setRemoving] = useState<Report | null>(null);
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [notice, setNotice] = useState(""), [saved, setSaved] = useState<Saved | null>(null);
useEffect(() => {
let active = true;
setBusy(true); setError(""); setData(null); setStats(null);
request<Options>(base + "/options").then(async config => {
if (!active) return;
setOptions(config);
const query: Query = { from: config.from, to: config.to, headquarters: [config.currentHeadquarters], objectId: initialObject?.id ?? null, type: null, search: "", page: 0, sort: "DATE", descending: true, splitType: false, splitCustomer: false };
setFilter(query); setSelected(initialObject ?? null);
const result = await post<Result>(base + "/query", query);
if (active) { setData(result); setApplied(query); }
}).catch(e => { if (active) setError(e.message); }).finally(() => { if (active) setBusy(false); });
return () => { active = false; };
}, [base, refresh, initialObject]);
async function load(query: Query, statistic = false) {
setBusy(true); setError("");
try {
if (statistic) { setStats(await post<Statistic>(base + "/statistics", query)); setStatsQuery(query); }
else { setData(await post<Result>(base + "/query", query)); setApplied(query); setStats(null); }
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
async function afterSave(result: Saved) {
setEditor(null); setSaved(result); setNotice(`Bericht ${result.id} gespeichert.`);
if (applied) await load(applied);
}
async function remove() {
if (!removing) return;
setBusy(true); setError("");
try {
await request(`${base}/${removing.id}`, { method: "DELETE", body: JSON.stringify({ revision: removing.revision }) });
setNotice(`Bericht ${removing.id} gelöscht.`); setRemoving(null);
if (applied) await load({ ...applied, page: 0 });
} catch (e) { setError((e as Error).message); setRemoving(null); } finally { setBusy(false); }
}
async function retryAppointments() {
if (!saved) return;
setBusy(true); setError("");
try { const result = await post<{ completed: number }>(`${base}/${saved.id}/complete-appointments`); setSaved(null); setNotice(`${result.completed} Terminerinnerungen abgeschlossen.`); }
catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
function exportStatistics() {
if (!stats || !statsQuery) return;
const q = statsQuery;
const header = ["Von", "Bis", "Niederlassung", "Mitarbeiter", ...(q.splitType ? ["Berichtstyp"] : []), ...(q.splitCustomer ? ["Kundentyp"] : []), "Anzahl Berichte"];
const rows = stats.rows.map(r => [q.from, q.to, r.headquarters, r.author, ...(q.splitType ? [r.type] : []), ...(q.splitCustomer ? [r.customerType] : []), r.total]);
const blob = new Blob(["\uFEFF" + [header, ...rows].map(row => row.map(csvCell).join(";")).join("\r\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = `votian-${kind === "CUSTOMER" ? "kunden" : "transporteurs"}berichte-statistik.csv`; link.click(); URL.revokeObjectURL(url);
}
const listFilter = (q: Query) => ({ ...q, page: 0, splitType: false, splitCustomer: false });
const pending = filter && applied && JSON.stringify(listFilter({ ...filter, objectId: selected?.id ?? null })) !== JSON.stringify(listFilter(applied));
const statisticPending = filter && statsQuery && JSON.stringify({ ...filter, objectId: selected?.id ?? null, page: 0, sort: "DATE", descending: true }) !== JSON.stringify({ ...statsQuery, page: 0, sort: "DATE", descending: true });
return <>
<div className="admin-title"><div><h2>{kind === "CUSTOMER" ? "Kundenberichte" : "Transporteursberichte"}</h2><p>Gespräche, Besuche und Vorgänge mit Berichtstyp und Vertraulichkeit dokumentieren.</p></div><button className="button primary" disabled={busy || !options} onClick={() => setEditor("new")}><Plus size={16} />Neuer Bericht</button></div>
{error && <div role="alert" className="alert error">{error}</div>}{notice && <p role="status" className="admin-success">{notice}</p>}
{saved?.objectId && options?.canCreateAppointments && <button className="button" onClick={() => setAppointmentSeed({ customer: { id: saved.objectId!, label: saved.objectLabel! }, text: `Folgetermin zu Bericht ${saved.id}` })}>Folgetermin zu Bericht {saved.id} anlegen</button>}
{saved?.warning && <div className="report-warning" role="alert"><p>{saved.warning}</p><button className="button" disabled={busy} onClick={() => void retryAppointments()}>Terminabschluss erneut versuchen</button></div>}
{options && filter && <form onSubmit={e => { e.preventDefault(); void load({ ...filter, objectId: selected?.id ?? null, page: 0 }); }}><fieldset disabled={busy}>
<div className="admin-toolbar"><Field label="Von"><input required type="date" value={filter.from} onChange={e => setFilter({ ...filter, from: e.target.value })} /></Field><Field label="Bis"><input required type="date" value={filter.to} onChange={e => setFilter({ ...filter, to: e.target.value })} /></Field><Field label="Berichtstypfilter"><select value={filter.type ?? ""} onChange={e => setFilter({ ...filter, type: e.target.value === "" ? null : Number(e.target.value) })}><option value="">Alle Berichtstypen</option><option value="0">Ohne Berichtstyp</option>{options.types.filter(t => t.id !== 0).map(t => <option key={t.id} value={t.id}>{t.label}</option>)}</select></Field><Field label="Textsuche"><input type="search" maxLength={200} value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} /></Field></div>
<div className="report-filters"><Lookup<ObjectOption> label={objectName} endpoint={`${base}/objects?headquarters=${filter.headquarters.join(",")}`} value={selected} describe={o => `${o.label}${o.headquarters ? ` · ${o.headquarters}` : ""}`} onSelect={setSelected} disabled={!filter.headquarters.length || busy} /><div><p className="admin-hint">Ohne Auswahl werden alle {kind === "CUSTOMER" ? "Kunden" : "Transporteure"} berücksichtigt.{kind === "CUSTOMER" && " Bei einem Hauptkunden sind direkte Unterkunden eingeschlossen."}</p><span className="form-hint">Niederlassungen</span><div className="report-headquarters">{options.headquarters.map(h => <label className="admin-check" key={h.id}><input type="checkbox" checked={filter.headquarters.includes(h.id)} onChange={e => { setFilter({ ...filter, headquarters: e.target.checked ? [...filter.headquarters, h.id] : filter.headquarters.filter(id => id !== h.id) }); setSelected(null); }} />{h.label}</label>)}</div>{options.headquarters.length > 1 && <div className="admin-row-actions"><button type="button" className="button" onClick={() => { setFilter({ ...filter, headquarters: options.headquarters.map(h => h.id) }); setSelected(null); }}>Alle Niederlassungen</button><button type="button" className="button" onClick={() => { setFilter({ ...filter, headquarters: [options.currentHeadquarters] }); setSelected(null); }}>Eigene Niederlassung</button></div>}</div></div>
<div className="admin-toolbar"><Field label="Sortierung"><select value={filter.sort} onChange={e => setFilter({ ...filter, sort: e.target.value })}>{Object.entries({ DATE: "Zeitpunkt", ID: "Nummer", HEADQUARTERS: "Niederlassung", OBJECT: objectName, AUTHOR: "Mitarbeiter", TEXT: "Text", TYPE: "Berichtstyp" }).map(([key, label]) => <option value={key} key={key}>{label}</option>)}</select></Field><label className="admin-check"><input type="checkbox" checked={filter.descending} onChange={e => setFilter({ ...filter, descending: e.target.checked })} />Absteigend</label><button className="button primary" disabled={!filter.headquarters.length}>Berichte anzeigen</button><button type="button" className="button" disabled={!filter.headquarters.length || !filter.from || !filter.to} onClick={() => void load({ ...filter, objectId: selected?.id ?? null, page: 0 }, true)}>Berichtsstatistik</button></div>
<div className="admin-toolbar"><label className="admin-check"><input type="checkbox" checked={filter.splitType} onChange={e => setFilter({ ...filter, splitType: e.target.checked })} />Statistik nach Berichtstyp aufteilen</label>{kind === "CUSTOMER" && <label className="admin-check"><input type="checkbox" checked={filter.splitCustomer} onChange={e => setFilter({ ...filter, splitCustomer: e.target.checked })} />Statistik nach Kundentyp aufteilen</label>}</div>
</fieldset></form>}
{busy && <p role="status"><LoaderCircle className="spin" size={17} /> Berichte werden geladen …</p>}
{stats && statsQuery && <section className="report-statistics"><div className="admin-title"><div><h3>Berichtsstatistik</h3>{statisticPending && <p className="admin-hint">Filter geändert. Die Statistik zeigt noch die vorherige Abfrage.</p>}<p>{statsQuery.from} bis {statsQuery.to} · {stats.total.toLocaleString("de-DE")} Berichte</p></div><button className="button" onClick={exportStatistics}><Download size={16} />Statistik als CSV</button></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Niederlassung</th><th>Mitarbeiter</th>{statsQuery.splitType && <th>Berichtstyp</th>}{statsQuery.splitCustomer && <th>Kundentyp</th>}<th>Anzahl</th></tr></thead><tbody>{stats.rows.map((r, i) => <tr key={i}><td>{r.headquarters}</td><td>{r.author}</td>{statsQuery.splitType && <td>{r.type}</td>}{statsQuery.splitCustomer && <td>{r.customerType}</td>}<td>{r.total}</td></tr>)}</tbody></table></div>{!stats.rows.length && <p>Keine Berichte im gewählten Zeitraum.</p>}</section>}
{data && <><div className="admin-title"><h3>{data.total.toLocaleString("de-DE")} {data.total === 1 ? "Bericht" : "Berichte"}</h3>{applied && <span className="admin-hint">{applied.from} bis {applied.to}</span>}</div>{pending && <p className="admin-hint">Die Berichtsliste zeigt noch die vorherige Abfrage. „Berichte anzeigen“ übernimmt die geänderten Filter.</p>}<div className="admin-table-wrap"><table className="admin-table report-table"><thead><tr><th>Nummer / Zeitpunkt</th><th>{objectName} / Niederlassung</th><th>Mitarbeiter</th><th>Eintrag</th><th>Berichtstyp / Aktionen</th></tr></thead><tbody>{data.items.map(r => <tr key={r.id}><td><strong>{r.id}</strong><small>{date(r.createdAt)}</small></td><td>{r.objectLabel || `Datensatz ${r.objectId}`}<small>{r.headquarters}</small></td><td>{r.author}<small>{r.phone}</small></td><td><div className="report-text">{r.text}</div>{r.confidential && <span className="report-confidential"><LockKeyhole size={14} />Vertraulich</span>}</td><td>{r.typeLabel}<div className="admin-row-actions"><button className="button" disabled={busy} onClick={() => setFormReport(r.id)}>Formulare</button>{options?.canCreateAppointments && <button className="button" disabled={busy} onClick={() => setAppointmentSeed({ customer: { id: r.objectId, label: r.objectLabel }, text: `Folgetermin zu Bericht ${r.id}` })}>Folgetermin</button>}{r.canEdit && <button className="button" disabled={busy} onClick={() => setEditor(r)} aria-label={`Bericht ${r.id} bearbeiten`}><Pencil size={15} />Bearbeiten</button>}{r.canDelete && <button className="icon-button" disabled={busy} onClick={() => setRemoving(r)} aria-label={`Bericht ${r.id} löschen`}><Trash2 size={16} /></button>}</div></td></tr>)}</tbody></table>{!data.items.length && <p className="report-empty"><FileText size={20} />Keine Berichte für diese Auswahl gefunden.</p>}</div><div className="admin-row-actions report-pagination"><button className="button" disabled={busy || !applied || data.page === 0} onClick={() => applied && void load({ ...applied, page: data.page - 1 })}>Vorherige Seite</button><span>Seite {data.page + 1} von {Math.max(1, Math.ceil(data.total / data.pageSize))}</span><button className="button" disabled={busy || !applied || (data.page + 1) * data.pageSize >= data.total} onClick={() => applied && void load({ ...applied, page: data.page + 1 })}>Nächste Seite</button></div></>}
{formReport!==null && <ReportForms kind={kind} id={formReport} onClose={()=>setFormReport(null)} onDirty={onDirtyChange}/>}
{appointmentSeed && <AppointmentEditor seed={appointmentSeed} onClose={() => setAppointmentSeed(null)} onDirtyChange={onDirtyChange} onSaved={id => { setAppointmentSeed(null); setNotice(`Folgetermin ${id} gespeichert.`); }} />}
{editor && options && <ReportEditor kind={kind} report={editor} initialObject={selected} options={options} onDirtyChange={onDirtyChange} onClose={() => setEditor(null)} onSaved={afterSave} />}
{removing && <Modal title="Bericht löschen" onClose={() => { if (!busy) setRemoving(null); }}><div className="admin-dialog-body"><p>Bericht {removing.id} für {removing.objectLabel} endgültig löschen?</p><p>Diese Aktion kann nicht rückgängig gemacht werden.</p><div className="admin-row-actions"><button className="button" disabled={busy} onClick={() => setRemoving(null)}>Abbrechen</button><button className="button danger" disabled={busy} onClick={() => void remove()}>Bericht löschen</button></div></div></Modal>}
</>;
}
function ReportEditor({ kind, report, initialObject, options, onClose, onSaved, onDirtyChange }: { kind: Kind; report: Report | "new"; initialObject: ObjectOption | null; options: Options; onClose: () => void; onSaved: (result: Saved) => Promise<void>; onDirtyChange: (dirty: boolean) => void }) {
const existing = report === "new" ? null : report;
const [object, setObject] = useState<ObjectOption | null>(existing ? { id: existing.objectId, label: existing.objectLabel } : initialObject);
const [text, setText] = useState(existing?.text || ""), [type, setType] = useState(existing?.type || 0), [confidential, setConfidential] = useState(existing?.confidential || false);
const [busy, setBusy] = useState(false), [error, setError] = useState(""), [confirmMisc, setConfirmMisc] = useState(false);
const [askDiscard, discardDialog] = useDiscardConfirmation();
const changed = text !== (existing?.text || "") || type !== (existing?.type || 0) || confidential !== (existing?.confidential || false) || !existing && object?.id !== initialObject?.id;
useEffect(() => { onDirtyChange(changed || busy); return () => onDirtyChange(false); }, [changed, busy, onDirtyChange]);
async function close() { if (!busy && (!changed || await askDiscard("Ungespeicherten Bericht verwerfen?"))) onClose(); }
async function save(e?: FormEvent) {
e?.preventDefault();
if (!object || !text.trim()) { setError("Bitte eine Zuordnung und einen Berichtstext eingeben."); return; }
if (options.miscType && type === Number(options.miscType) && !confirmMisc) { setConfirmMisc(true); return; }
setConfirmMisc(false); setBusy(true); setError("");
const body = { revision: existing?.revision, objectId: object.id, type, text, confidential };
try {
const result = existing ? await put<Saved>(`/administration/reports/${kind}/${existing.id}`, body) : await post<Saved>(`/administration/reports/${kind}`, body);
await onSaved({ ...result, objectId: object.id, objectLabel: object.label });
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return <><Modal title={existing ? `Bericht ${existing.id} bearbeiten` : "Neuer Bericht"} onClose={() => void close()} wide><form className="admin-dialog-body" onSubmit={save}>{error && <div role="alert" className="alert error">{error}</div>}<fieldset disabled={busy}>
{existing ? <p><strong>{existing.objectLabel}</strong><br />{existing.author} · {date(existing.createdAt)}</p> : <><Lookup<ObjectOption> label={kind === "CUSTOMER" ? "Kunde" : "Transporteur"} endpoint={`/administration/reports/${kind}/objects?headquarters=${options.headquarters.map(h => h.id).join(",")}`} value={object} describe={o => `${o.label}${o.headquarters ? ` · ${o.headquarters}` : ""}`} onSelect={setObject} disabled={busy} /><p className="admin-hint">Der Bericht wird in Ihrer Niederlassung {options.headquarters.find(h => h.id === options.currentHeadquarters)?.label} mit dem aktuellen Zeitpunkt angelegt.</p></>}
<Field label="Berichtstyp"><select value={type} onChange={e => setType(Number(e.target.value))}><option value={0}>Ohne Berichtstyp</option>{options.types.filter(t => t.id !== 0).map(t => <option key={t.id} value={t.id}>{t.label}</option>)}{type !== 0 && !options.types.some(t => t.id === type) && <option value={type}>Typ {type} (nicht mehr verfügbar – bitte neu wählen)</option>}</select></Field>
<Field label="Berichtstext"><textarea rows={9} required maxLength={65535} value={text} onChange={e => setText(e.target.value)} /></Field>
<label className="admin-check"><input type="checkbox" checked={confidential} onChange={e => setConfidential(e.target.checked)} /><LockKeyhole size={15} />Vertraulich – nur für Verfasser und Benutzer mit Masterrecht sichtbar</label>
<div className="admin-row-actions"><button className="button" type="button" onClick={() => void close()}>Abbrechen</button><button className="button primary" disabled={!object || !text.trim() || !!existing && !changed}><Save size={16} />Bericht speichern</button></div>
</fieldset></form></Modal>{discardDialog}{confirmMisc && <Modal title="Berichtstyp bestätigen" onClose={() => setConfirmMisc(false)}><div className="admin-dialog-body"><p>Den Bericht mit dem Typ „{options.types.find(t => t.id === type)?.label}“ speichern?</p><div className="admin-row-actions"><button className="button" onClick={() => setConfirmMisc(false)}>Zurück</button><button className="button primary" onClick={() => void save()}>Mit diesem Typ speichern</button></div></div></Modal>}</>;
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { post } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
import { download } from "./CrmContacts";
import type { CrmOptions } from "./SalesWorkspace";
type Conversion = { person: string; headquarters: string; customerNumber: string; company: string; time: string; source: string; previousStatus: string; revenue: number };
type Growth = { person: string; headquarters: string; customerNumber: string; company: string; current: number; previous: number; growth: number };
type Switches = { conversions: Conversion[]; summary: { person: string; headquarters: string; counts: Record<string, number> }[]; growth: Growth[]; from: string; to: string; previousFrom: string; previousTo: string };
type Motivation = { month: string; through: string; items: { id: number; label: string; jobs: number; actual: number; target: number; remaining: number; percent: number | null }[] };
const money = (n: number) => n.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
const percent = (n: number) => `${n.toLocaleString("de-DE", { maximumFractionDigits: 2 })} %`;
const columns = { jb1: "Interessent · Auftrag", jb2: "Potentialkunde · Auftrag", jb3: "Wunschkunde · Auftrag", cs1: "Interessent · Stamm", cs2: "Potentialkunde · Stamm", cs3: "Wunschkunde · Stamm" };
export function SalesCounters({ kind, options, onDirtyChange }: { kind: "switches" | "motivation"; options: CrmOptions; onDirtyChange: (d: boolean) => void }) {
const [from, setFrom] = useState(options.today.slice(0, 4) + "-01-01"), [to, setTo] = useState(options.today), [month, setMonth] = useState(options.today.slice(0, 7));
const [hqs, setHqs] = useState(options.motivationMode === "1" && kind === "motivation" ? options.headquarters.filter(h => h.id !== 203).map(h => h.id) : [options.currentHeadquarters]);
const [switches, setSwitches] = useState<Switches | null>(null), [motivation, setMotivation] = useState<Motivation | null>(null), [previous, setPrevious] = useState<Motivation | null>(null), [showMoney, setShowMoney] = useState(true), [targets, setTargets] = useState(false);
const [busy, setBusy] = useState(false), [error, setError] = useState("");
async function load() { setBusy(true); setError(""); try { if (kind === "switches") setSwitches(await post<Switches>("/crm/switches", { from, to, headquarters: hqs })); else { setMotivation(await post<Motivation>("/crm/motivation", { month, headquarters: hqs })); setPrevious(null); if (options.previousMonth && +options.today.slice(8) < 10 && month === options.today.slice(0, 7)) { const date = new Date(`${month}-01T12:00:00`); date.setMonth(date.getMonth() - 1); setPrevious(await post<Motivation>("/crm/motivation", { month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, headquarters: hqs })); } } } catch (e) { setError((e as Error).message); setSwitches(null); setMotivation(null); } finally { setBusy(false); } }
useEffect(() => { void load(); }, []);
function csv() { if (!switches) return; const rows: unknown[][] = [["Auswertung", "Mitarbeiter", "NL", "Kundennummer", "Firma", "Quelle", "Vorheriger Status", "Zeit", "Umsatz", "Vorjahresumsatz", "Zuwachs"]]; for (const c of switches.conversions) rows.push(["Statuswechsel", c.person, c.headquarters, c.customerNumber, c.company, c.source, c.previousStatus, c.time, c.revenue, "", ""]); for (const g of switches.growth) rows.push(["Bestandskunden", g.person, g.headquarters, g.customerNumber, g.company, "", "", "", g.current, g.previous, g.growth]); const safe = (value: unknown) => { let s = String(value ?? ""); if (typeof value === "string" && /^[\s]*[=+@-]/.test(s)) s = "'" + s; return `"${s.replace(/"/g, '""')}"`; }; download("\uFEFF" + rows.map(r => r.map(safe).join(";")).join("\r\n"), `Kundenstatuswechsel-${switches.from}-${switches.to}.csv`, "text/csv;charset=utf-8"); }
function renderMotivation(data: Motivation) { const own = data.items.find(r => r.id === options.currentHeadquarters); return <><p>Monat {data.month} · Umsätze bis {data.through}</p><div className="crm-contact-grid">{data.items.map(r => <article className="crm-motivation" key={r.id}><h3>{r.label} <small>{r.jobs} Aufträge</small></h3><strong>{r.percent === null ? "Kein Monatsziel hinterlegt" : percent(r.percent)}</strong><progress aria-label={`Zielerreichung ${r.label}`} value={Math.max(0, Math.min(100, r.percent ?? 0))} max={100} />{showMoney && <dl><div><dt>Ist-Umsatz</dt><dd>{money(r.actual)}</dd></div><div><dt>Monatsziel</dt><dd>{money(r.target)}</dd></div>{r.target > 0 && <div><dt>{r.remaining < 0 ? "Ziel übertroffen um" : "Bis zum Ziel"}</dt><dd>{money(Math.abs(r.remaining))}</dd></div>}</dl>}{own && own.id !== r.id && <p className="admin-hint">Vergleich mit {own.label}: {own.actual > 0 ? `${percent((r.actual / own.actual - 1) * 100)} Umsatz` : "kein Umsatzvergleich möglich"}{r.percent !== null && own.percent !== null ? ` · ${(r.percent - own.percent).toLocaleString("de-DE", { maximumFractionDigits: 2 })} Prozentpunkte Zielerreichung` : ""}</p>}</article>)}</div></>; }
return <section className="panel crm-panel"><div className="admin-title"><div><h2>{kind === "switches" ? "Kundenstatuswechsel" : "Motivation-Counter"}</h2><p>{kind === "switches" ? "Wechsel zum Kunden, verantwortliche Mitarbeiter und Umsatzentwicklung." : "Monatsumsatz, Zielerreichung und Niederlassungsvergleich."}</p></div>{kind === "motivation" && <button className="button" onClick={() => setTargets(true)}>Zielvorgaben verwalten</button>}</div>
{error && <p role="alert" className="alert error">{error}</p>}<form onSubmit={e => { e.preventDefault(); void load(); }}><fieldset disabled={busy}><div className="admin-toolbar">{kind === "switches" ? <><Field label="Statuswechsel von"><input required type="date" value={from} onInput={e => setFrom(e.currentTarget.value)} /></Field><Field label="Statuswechsel bis"><input required type="date" value={to} onInput={e => setTo(e.currentTarget.value)} /></Field></> : <Field label="Umsatzmonat"><input required type="month" max={options.today.slice(0, 7)} value={month} onInput={e => setMonth(e.currentTarget.value)} /></Field>}<button className="button primary" disabled={!hqs.length}>Anzeigen</button>{kind === "switches" && <button type="button" className="button" disabled={!switches} onClick={csv}>CSV exportieren</button>}</div><div className="admin-toolbar">{options.headquarters.filter(h => kind !== "motivation" || h.id !== 203).map(h => <label key={h.id} className="admin-check"><input type="checkbox" checked={hqs.includes(h.id)} onChange={e => setHqs(e.target.checked ? [...hqs, h.id] : hqs.filter(id => id !== h.id))} />{h.label}</label>)}<button className="button" type="button" onClick={() => setHqs([options.currentHeadquarters])}>Eigene Niederlassung</button><button className="button" type="button" onClick={() => setHqs(options.headquarters.filter(h => kind !== "motivation" || h.id !== 203).map(h => h.id))}>Alle</button></div></fieldset></form>
{busy && <p role="status">Auswertung wird geladen …</p>}
{switches && <><p>{switches.from} bis {switches.to} · Vorjahresvergleich: {switches.previousFrom} bis {switches.previousTo}</p><div className="crm-table-scroll"><table className="data-table"><thead><tr><th>Mitarbeiter</th><th>NL</th>{Object.values(columns).map(c => <th key={c}>{c}</th>)}</tr></thead><tbody>{switches.summary.map((s, i) => <tr key={i}><td>{s.person}</td><td>{s.headquarters}</td>{Object.keys(columns).map(c => <td key={c}>{s.counts[c] ?? 0}</td>)}</tr>)}</tbody></table></div><h3>{switches.conversions.length} Wechsel zum Kunden</h3><div className="crm-table-scroll"><table className="data-table"><thead><tr><th>Datum</th><th>Mitarbeiter · NL</th><th>Kunde</th><th>Vorher</th><th>Quelle</th><th>Umsatz im Zeitraum</th></tr></thead><tbody>{switches.conversions.map((c, i) => <tr key={i}><td>{new Date(c.time).toLocaleString("de-DE")}</td><td>{c.person} · {c.headquarters}</td><td>{c.company}<small>{c.customerNumber}</small></td><td>{c.previousStatus}</td><td>{c.source}</td><td>{money(c.revenue)}</td></tr>)}</tbody></table></div><h3>Umsatzentwicklung zugeordneter Bestandskunden</h3><div className="crm-table-scroll"><table className="data-table"><thead><tr><th>Mitarbeiter · NL</th><th>Kunde</th><th>Umsatz</th><th>Vorjahr</th><th>Zuwachs</th></tr></thead><tbody>{switches.growth.map((g, i) => <tr key={i}><td>{g.person} · {g.headquarters}</td><td>{g.company}<small>{g.customerNumber}</small></td><td>{money(g.current)}</td><td>{money(g.previous)}</td><td>{money(g.growth)}</td></tr>)}</tbody></table></div>{!switches.summary.length && <p>Keine Statuswechsel oder zugeordneten Bestandskunden im gewählten Bereich.</p>}</>}
{motivation && <><label className="admin-check"><input type="checkbox" checked={showMoney} onChange={e => setShowMoney(e.target.checked)} />Umsatzbeträge anzeigen</label>{renderMotivation(motivation)}{previous && <section><h3>Vormonat</h3>{renderMotivation(previous)}</section>}</>}
{targets && <TargetEditor options={options} onDirtyChange={onDirtyChange} onClose={() => setTargets(false)} onSaved={() => void load()} />}</section>;
}
function TargetEditor({ options, onDirtyChange, onClose, onSaved }: { options: CrmOptions; onDirtyChange: (d: boolean) => void; onClose: () => void; onSaved: () => void }) {
const [hq, setHq] = useState(options.currentHeadquarters), [month, setMonth] = useState(options.today.slice(0, 7)), [amount, setAmount] = useState(""), [password, setPassword] = useState("");
const [items, setItems] = useState<{ month: string; amount: string; revision: string }[] | null>(null), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false), [changed, setChanged] = useState(false);
const [ask, dialog] = useDiscardConfirmation();
useEffect(() => { onDirtyChange(changed || busy); return () => onDirtyChange(false); }, [changed, busy, onDirtyChange]);
async function list() { setBusy(true); setError(""); try { const rows = await post<typeof items>("/crm/targets/query", { headquarters: hq, password }); setItems(rows); setAmount(rows?.find(r => r.month === month)?.amount ?? ""); setChanged(false); } catch (e) { setItems(null); setError((e as Error).message); } finally { setBusy(false); } }
async function save() { setBusy(true); setError(""); try { await post("/crm/targets", { headquarters: hq, month, amount: Number(amount.replace(",", ".")), password, revision: items?.find(r => r.month === month)?.revision ?? null }); setNotice("Zielvorgabe gespeichert."); setChanged(false); await list(); onSaved(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
return <><Modal title="Zielvorgaben verwalten" onClose={() => { void (async () => { if (!busy && (!changed || await ask("Ungespeicherte Zielvorgabe verwerfen?"))) onClose(); })(); }}><div className="admin-dialog-body">{error && <p className="alert error" role="alert">{error}</p>}{notice && <p role="status">{notice}</p>}<p>Zum Lesen und Ändern gilt das vorhandene Passwort des Motivation-Counters.</p><fieldset disabled={busy}><Field label="Niederlassung für Zielvorgaben"><select value={hq} onChange={e => { const next = +e.target.value; void (async () => { if (changed && !await ask("Ungespeicherte Zielvorgabe verwerfen?")) return; setHq(next); setItems(null); setPassword(""); setAmount(""); setChanged(false); })(); }}>{options.headquarters.map(h => <option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><Field label="Passwort für Zielvorgaben"><input type="password" autoComplete="off" value={password} onChange={e => { setPassword(e.target.value); setItems(null); }} /></Field><button className="button" disabled={!password} onClick={() => void list()}>Zielvorgaben laden</button>{items && <><Field label="Zielmonat"><input type="month" min={options.today.slice(0, 7)} value={month} onInput={e => { const next = e.currentTarget.value; void (async () => { if (changed && !await ask("Ungespeicherte Zielvorgabe verwerfen?")) return; setMonth(next); setAmount(items.find(r => r.month === next)?.amount ?? ""); setChanged(false); })(); }} /></Field><Field label="Monatsziel in Euro"><input inputMode="decimal" value={amount} onChange={e => { setAmount(e.target.value); setChanged(true); }} /></Field><button className="button primary" disabled={!changed || !/^\d+([.,]\d{1,2})?$/.test(amount)} onClick={() => void save()}>Zielvorgabe speichern</button><h3>Vorhandene Zielvorgaben</h3>{items.length ? <dl>{items.map(r => <div key={r.month}><dt>{r.month}</dt><dd>{money(Number(r.amount))}</dd></div>)}</dl> : <p>Noch keine Zielvorgaben hinterlegt.</p>}</>}</fieldset></div></Modal>{dialog}</>;
}
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
import { request } from "./api";
import { useDiscardConfirmation } from "./FormElements";
import { Appointments as Calendar } from "./Appointments";
import { CrmContacts } from "./CrmContacts";
import { SalesCounters } from "./SalesCounters";
export type CrmOptions = { headquarters: { id: number; label: string }[]; currentHeadquarters: number; today: string; motivationMode: string; previousMonth: boolean };
export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDirtyChange: (d: boolean) => void }) {
const [tab, setTab] = useState("calendar"), [dirty, setDirty] = useState(false), [options, setOptions] = useState<CrmOptions | null>(null), [error, setError] = useState("");
const [ask, dialog] = useDiscardConfirmation();
useEffect(() => { setDirty(false); setError(""); let active = true; request<CrmOptions>("/crm/options").then(o => { if (active) setOptions(o); }).catch(e => { if (active) setError(e.message); }); return () => { active = false; }; }, [refresh]);
useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]);
async function change(next: string) { if (next === tab) return; if (!dirty || await ask("Ungespeicherte Vertriebsänderungen verwerfen?")) { setDirty(false); setTab(next); } }
return <><nav className="appointment-tabs crm-nav" aria-label="Vertriebsbereiche">{Object.entries({ calendar: "Termine", contacts: "Kontakte", switches: "Kundenstatuswechsel", motivation: "Motivation-Counter" }).map(([key, name]) => <button key={key} className={`button ${tab === key ? "primary" : ""}`} aria-pressed={tab === key} onClick={() => void change(key)}>{name}</button>)}</nav>{error && <p role="alert">{error}</p>}{tab === "calendar" ? <Calendar refresh={refresh} onDirtyChange={setDirty} /> : options ? tab === "contacts" ? <CrmContacts key={refresh} options={options} onDirtyChange={setDirty} /> : <SalesCounters key={`${tab}-${refresh}`} kind={tab as "switches" | "motivation"} options={options} onDirtyChange={setDirty} /> : <p>Vertriebsoptionen werden geladen …</p>}{dialog}</>;
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { LoaderCircle, Plus, RefreshCw, Save, Settings2, Trash2 } from "lucide-react";
import { ApiError, post, put, request } from "./api";
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
type Query={mode:number;customer:number;date:string};
type Options={today:string;defaultMode:number;general:boolean;customers:boolean};
type Definition={id:number;name:string;groups:string;customer:number};
type Cell={service:number;type:number;price:string;discount:string;courier:string;courierDiscount:string;ambiguous:boolean};
type Grid={columns:Definition[];rows:Definition[];cells:Cell[];revision:string;scope:number;future:boolean;futureDates:{date:string}[]};
type Draft={service:number;type:number;value:string;courier:string};
type SaveRequest={requestId:string;query:Query;revision:string;discount:boolean;cells:Draft[]};
type Option={id:number;label:string};
const base="/administration/service-prices";
const cellKey=(c:{service:number;type:number})=>`${c.service}:${c.type}`;
const equal=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b);
const normalize=(value:string)=>{const v=value.trim().replace(",",".");return !v?"0.00":/^-?[0-9]{1,5}(\.[0-9]{1,2})?$/.test(v)?Number(v).toFixed(2):"invalid:"+value;};
const safeFailure=(e:unknown)=>e instanceof ApiError&&e.status>=400&&e.status<500;
export function ServicePrices({discount,onDirty}:{discount:boolean;onDirty:(dirty:boolean)=>void}) {
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query>({mode:0,customer:0,date:""}),[loaded,setLoaded]=useState<Query|null>(null);
const [grid,setGrid]=useState<Grid|null>(null),[draft,setDraft]=useState<Record<string,Draft>>({}),[busy,setBusy]=useState(false),[error,setError]=useState(""),[notice,setNotice]=useState("");
const [customerSearch,setCustomerSearch]=useState(""),[customers,setCustomers]=useState<Option[]>([]),[searchBusy,setSearchBusy]=useState(false);
const [showCustomer,setShowCustomer]=useState(true),[showCourier,setShowCourier]=useState(true),[filter,setFilter]=useState(""),[definitions,setDefinitions]=useState(false);
const [pending,setPending]=useState<SaveRequest|null>(null),[confirmDiscard,discardDialog]=useDiscardConfirmation();
const changed=grid?.cells.filter(c=>{const d=draft[cellKey(c)];return d&&(normalize(d.value)!==normalize(discount?c.discount:c.price)||normalize(d.courier)!==normalize(discount?c.courierDiscount:c.courier));})||[];
const dirty=changed.length>0||!!pending;
useEffect(()=>{onDirty(dirty||definitions);return()=>onDirty(false);},[dirty,definitions,onDirty]);
function accept(data:Grid,q:Query){setGrid(data);setLoaded(q);setDraft(Object.fromEntries(data.cells.map(c=>[cellKey(c),{service:c.service,type:c.type,value:discount?c.discount:c.price,courier:discount?c.courierDiscount:c.courier}])));setPending(null);}
useEffect(()=>{let active=true;request<Options>(base).then(async o=>{if(!active)return;setOptions(o);const q={mode:o.defaultMode,customer:0,date:o.today};setQuery(q);if(o.general){setBusy(true);const data=await post<Grid>(base+"/query",q);if(active)accept(data,q);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[]);
async function load(q:Query=query,guard=true){if(guard&&dirty&&!await confirmDiscard(pending?"Speicherstand unklar. Entwurf verwerfen und tatsächliche Tarife neu laden?":"Ungespeicherte Tarifänderungen verwerfen?"))return;setBusy(true);setError("");try{accept(await post<Grid>(base+"/query",q),q);setNotice("");}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
async function findCustomers(e:FormEvent){e.preventDefault();setSearchBusy(true);setError("");try{setCustomers(await request<Option[]>(base+"/customers?q="+encodeURIComponent(customerSearch)));}catch(e){setError((e as Error).message);}finally{setSearchBusy(false);}}
async function save(){if(!grid||!loaded)return;const body=pending||{requestId:crypto.randomUUID(),query:loaded,revision:grid.revision,discount,cells:changed.map(c=>draft[cellKey(c)])};setPending(body);setBusy(true);setError("");try{await put(base,body);setPending(null);setDraft({});setGrid(null);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{setBusy(false);}}
const selected=!!loaded&&equal(query,loaded),frozen=busy||!!pending||!selected;
const priceLabel=discount?"Rabatt (%)":"Preis",courierLabel=discount?"Frachtvergütungsrabatt (%)":"Frachtvergütung";
const visibleRows=grid?.rows.filter(r=>r.name.toLocaleLowerCase().includes(filter.toLocaleLowerCase()))||[];
const indexed=new Map(grid?.cells.map(c=>[cellKey(c),c]));
function update(key:string,field:"value"|"courier",value:string){setDraft(d=>({...d,[key]:{...d[key],[field]:value}}));}
return <><div className="admin-title"><div><h2>{discount?"Servicerabatte":"Servicepreise"}</h2><p>Allgemeine und kundenspezifische Tarife mit Frachtvergütungen.</p></div>{grid&&<strong>{grid.rows.length} × {grid.columns.length}</strong>}</div>
{error&&<div className="alert error" role="alert">{error}</div>}{notice&&<p className="admin-success" role="status">{notice}</p>}
{!options?(!error?<LoaderCircle className="spin"/>:null):<>
{options.customers&&<form className="admin-toolbar" onSubmit={findCustomers}><Field label="Kunde suchen"><input value={customerSearch} onChange={e=>setCustomerSearch(e.target.value)} maxLength={80} 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)}))}>{options.general?<option value={0}>Allgemeine Tarife</option>:<option value={0} disabled>Bitte Kunde auswählen</option>}{!customers.some(c=>c.id===query.customer)&&query.customer>0&&<option value={query.customer}>Kunde {query.customer}</option>}{customers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field>
<Field label="Servicemodus"><select value={query.mode} onChange={e=>{const mode=Number(e.target.value);setQuery(q=>({...q,mode,date:mode===1?options.today:q.date}));}}><option value={0}>Modus 0 · Fahrzeugtarife</option><option value={1}>Modus 1 · Leistungen</option></select></Field>
<Field label="Gültig ab"><input type="date" value={query.date} min={options.today} disabled={query.mode===1} onInput={e=>{const date=e.currentTarget.value;setQuery(q=>({...q,date}));}}/></Field>
<button className="button" disabled={!query.date||(!options.general&&!query.customer)}><RefreshCw size={16}/>Tarife laden</button>
</div></fieldset></form>
{grid&&loaded&&<><p className="admin-hint">{loaded.customer?`Kunde ${loaded.customer}`:"Allgemeine Tarife"} · {grid.scope?`Niederlassung ${grid.scope}`:"Gemeinsamer Tarifbestand aller Niederlassungen"} · {grid.future?`Geplant für ${loaded.date}`:"Aktuell gültig"}</p>
{!selected&&<p role="status">Auswahl geändert. Bitte „Tarife laden“ wählen.</p>}
{grid.future&&<p className="admin-hint">Diese Ansicht enthält ausschließlich die für diesen Tag geplanten Werte. Nicht belegte Zellen stehen wie in der Altanwendung auf 0. Änderungen werden erst zum Gültigkeitsdatum wirksam.</p>}
{!!grid.futureDates.length&&query.mode===0&&<Field label="Vorhandene künftige Tarifstände"><select value="" disabled={frozen} onChange={e=>{const date=e.target.value;if(date)setQuery(q=>({...q,date}));}}><option value="">Datum auswählen …</option>{grid.futureDates.map(d=><option key={d.date} value={d.date}>{d.date}</option>)}</select></Field>}
<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><Field label="Zeilen filtern"><input value={filter} onChange={e=>setFilter(e.target.value)} placeholder="Leistung oder Fahrzeug"/></Field></div>
<div className="admin-table-wrap service-price-wrap"><table className="admin-table service-price-table"><thead><tr><th scope="col">{loaded.mode===0?"Fahrzeug":"Leistung"}</th>{grid.columns.map(c=><th scope="col" key={c.id}>{c.name}</th>)}</tr></thead><tbody>{visibleRows.map(r=><tr key={r.id}><th scope="row">{r.name}</th>{grid.columns.map(col=>{const key=cellKey({service:col.id,type:r.id}),c=indexed.get(key),d=draft[key];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(key,"value",e.target.value)}/></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(key,"courier",e.target.value)}/></label>}</fieldset>{c.ambiguous&&<small role="status">Bestand mehrdeutig</small>}</>:"–"}</td>;})}</tr>)}</tbody></table></div>
{!grid.rows.length||!grid.columns.length?<p>Für diese Auswahl sind keine Preis-/Leistungstypen zugeordnet.</p>:null}
<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)}><RefreshCw size={16}/>Gespeicherten Stand laden</button>{loaded.mode===1&&<button className="button" disabled={dirty||busy||!selected} onClick={()=>setDefinitions(true)}><Settings2 size={16}/>Preis-/Leistungstypen</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>}
</>}
{definitions&&loaded&&<DefinitionManager query={loaded} onClose={()=>setDefinitions(false)} onSaved={()=>void load(loaded,false)}/>}{discardDialog}
</>;
}
type ManagedDefinition={id:number;name:string;groups:number[];revision:string};
type DefinitionOptions={prices:ManagedDefinition[];benefits:ManagedDefinition[];canDelete:boolean;canGroup:boolean;groups:Option[];pending:{token:string}[]};
type DefinitionDraft={kind:"PRICE"|"BENEFIT";id:number;name:string;groups:number[];revision:string;remove:boolean};
type DefinitionRequest=DefinitionDraft&{query:Query;requestId:string};
function DefinitionManager({query,onClose,onSaved}:{query:Query;onClose:()=>void;onSaved:()=>void}) {
const [data,setData]=useState<DefinitionOptions|null>(null),[edit,setEdit]=useState<DefinitionDraft|null>(null),[original,setOriginal]=useState<DefinitionDraft|null>(null),[error,setError]=useState(""),[notice,setNotice]=useState(""),[busy,setBusy]=useState(false),[pending,setPending]=useState<DefinitionRequest|null>(null);
const [confirmDiscard,discardDialog]=useDiscardConfirmation();const gate=useRef(false);
const dirty=!!pending||!!edit&&!equal(edit,original);
async function refresh(){setData(await post<DefinitionOptions>(base+"/definitions/query",query));}
useEffect(()=>{let active=true;post<DefinitionOptions>(base+"/definitions/query",query).then(d=>{if(active)setData(d);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[]);
async function close(){if(busy)return;if(!dirty||await confirmDiscard("Ungespeicherte Typänderung verwerfen?")){if(edit){setEdit(null);setPending(null);}else onClose();}}
function start(kind:"PRICE"|"BENEFIT",d?:ManagedDefinition,remove=false){const draft={kind,id:d?.id||0,name:d?.name||"",groups:d?.groups||[],revision:d?.revision||"",remove};setEdit(draft);setOriginal(draft);setError("");setNotice("");}
async function save(e:FormEvent){e.preventDefault();if(!edit||gate.current)return;gate.current=true;setBusy(true);setError("");const body=pending||{...edit,query,requestId:crypto.randomUUID()};setPending(body);try{const result=await put<{state:string;message?:string}>(base+"/definitions",body);setPending(null);setEdit(null);setNotice(result.message||"Preis-/Leistungstyp gespeichert.");await refresh();onSaved();}catch(e){if(safeFailure(e))setPending(null);setError((e as Error).message);}finally{gate.current=false;setBusy(false);}}
async function resume(token:string){setBusy(true);setError("");try{const r=await post<{message?:string}>(base+`/definitions/${token}/resume`);setNotice(r.message||"Protokolleintrag übernommen.");await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title={edit?(edit.remove?"Typ löschen":edit.id?"Typ bearbeiten":"Typ anlegen"):"Preis- und Leistungstypen"} onClose={()=>void close()} wide>
{error&&<div className="alert error" role="alert">{error}</div>}{notice&&<p className="admin-success" role="status">{notice}</p>}
{edit?<form onSubmit={save}><fieldset disabled={busy||!!pending}>{edit.remove?<><p>„{edit.name}“ aus dem Leistungskatalog entfernen?</p><p>Die zugehörigen Tarif- und Auftragshistorien bleiben erhalten.</p></>:<><Field label={edit.kind==="PRICE"?"Preistyp":"Leistungstyp"}><input value={edit.name} required maxLength={50} onChange={e=>{const name=e.target.value;setEdit(d=>d&&({...d,name}));}}/></Field>{data?.canGroup&&<fieldset><legend>Gruppen</legend>{data.groups.map(g=><label className="service-group" key={g.id}><input type="checkbox" checked={edit.groups.includes(g.id)} onChange={e=>{const checked=e.target.checked;setEdit(d=>d&&({...d,groups:checked?[...d.groups,g.id]:d.groups.filter(id=>id!==g.id)}));}}/>{g.label}</label>)}</fieldset>}</>}</fieldset><div className="modal-actions"><button type="button" className="button" disabled={busy} onClick={()=>void close()}>Abbrechen</button><button className="button primary" disabled={busy||(!edit.remove&&!edit.name.trim())}>{pending?"Speicherstatus erneut prüfen":edit.remove?"Typ löschen":"Typ speichern"}</button></div></form>:<>
{!data?<LoaderCircle className="spin"/>:<><div className="service-definitions">{(["PRICE","BENEFIT"] as const).map(kind=><section key={kind}><div className="admin-title"><h3>{kind==="PRICE"?"Preistypen":"Leistungstypen"}</h3><button className="button" disabled={busy} onClick={()=>start(kind)}><Plus size={16}/>Neu</button></div><ul>{(kind==="PRICE"?data.prices:data.benefits).map(d=><li key={d.id}><span>{d.name}</span><button className="button" disabled={busy} onClick={()=>start(kind,d)}>Bearbeiten</button>{data.canDelete&&<button className="icon-button" disabled={busy} aria-label={`${d.name} löschen`} onClick={()=>start(kind,d,true)}><Trash2 size={16}/></button>}</li>)}</ul></section>)}</div>{data.pending.map(p=><div className="admin-toolbar" key={p.token}><span>Gespeicherte Änderung: Protokoll noch offen</span><button className="button" disabled={busy} onClick={()=>void resume(p.token)}>Protokoll übernehmen</button></div>)}</>}
</>}{discardDialog}
</Modal>;
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState, type FormEvent } from "react";
import { ArrowDownToLine, BarChart3, LoaderCircle, Plus, X } from "lucide-react";
import { request, post } from "./api";
import { Field } from "./FormElements";
type Option = {id:number;label:string};
type Options = {headquarters:Option[];currentHeadquarters:number;groups:Option[];excludeFreeCancellations:boolean;relatedCustomer:boolean};
type Interval = {from:string;to:string};
type Row = {period:string;orders:number;revenue:number;courierRevenue:number|null;missingCourierValues:number;serviceRevenue:number;fuelMarkup:number;courierService:number;kilometers:number|null;co2eKg:number|null;kilometersOrders:number;co2eKgOrders:number;[key:string]:string|number|null};
type Result = {periods:{interval:Interval;rows:Row[];totals:Row}[];splits:string[];dateBasis:string;period:string;completePeriod:boolean};
const splitLabels:Record<string,string>={CUSTOMER:"Kunde",COURIER:"Transporteur",HEADQUARTERS:"Niederlassung",COST_CENTER:"Kostenstelle",VEHICLE_TYPE:"Angeforderter Fahrzeugtyp",ACTUAL_VEHICLE_TYPE:"Gefahrener Fahrzeugtyp",VEHICLE:"Fahrzeugnummer",EMPLOYEE:"Mitarbeiter",ORDER_TYPE:"Auftragstyp",START_POSTAL:"Start-PLZ",END_POSTAL:"Ziel-PLZ",PARTNER_COMMISSION:"Partnerprovision"};
const dateLabels:Record<string,string>={FINISHED:"Erledigungsdatum",CREATED:"Erfassungsdatum",BOOKED:"Buchungsdatum",EXPORTED:"Exportdatum",ORDERED:"Auftragsdatum"};
const periodLabels:Record<string,string>={TOTAL:"Gesamt",DAY:"Täglich",WEEK:"Wöchentlich",MONTH:"Monatlich",YEAR:"Jährlich",HALF_HOUR:"Halbstündlich",HOUR:"Stündlich",TWO_HOURS:"Zweistündlich"};
const today=()=>{const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;};
const fmt=(n:number|null|undefined,digits=2)=>n==null?"–":new Intl.NumberFormat("de-DE",{maximumFractionDigits:digits,minimumFractionDigits:digits}).format(n);
const money=(n:number|null|undefined)=>n==null?"–":new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(n);
const bool=(value:string)=>value===""?null:value==="true";
function Select({label,value,onChange,values}:{label:string;value:string;onChange:(v:string)=>void;values:Record<string,string>}){return <Field label={label}><select value={value} onChange={e=>onChange(e.target.value)}>{Object.entries(values).map(([key,text])=><option key={key} value={key}>{text}</option>)}</select></Field>;}
export function Statistics() {
const [options,setOptions]=useState<Options|null>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[result,setResult]=useState<Result|null>(null);
const [interval,setInterval]=useState<Interval>({from:today().slice(0,8)+"01",to:today()}),[comparisons,setComparisons]=useState<Interval[]>([]),[headquarters,setHeadquarters]=useState<number[]>([]);
const [dateBasis,setDateBasis]=useState("FINISHED"),[state,setState]=useState("FINISHED"),[period,setPeriod]=useState("MONTH"),[splits,setSplits]=useState<string[]>(["HEADQUARTERS"]);
const [amount,setAmount]=useState("ALL"),[payment,setPayment]=useState("ALL"),[customerNumber,setCustomerNumber]=useState(""),[courierNumber,setCourierNumber]=useState(""),[customerGroup,setCustomerGroup]=useState(""),[courierGroup,setCourierGroup]=useState("");
const [retrospective,setRetrospective]=useState(""),[permanent,setPermanent]=useState(""),[longhaul,setLonghaul]=useState(""),[timeFrom,setTimeFrom]=useState(""),[timeTo,setTimeTo]=useState("");
const [excludeFreeCancellations,setExcludeFreeCancellations]=useState(false),[relatedCustomer,setRelatedCustomer]=useState(false),[showFilters,setShowFilters]=useState(false);
useEffect(()=>{let active=true;request<Options>("/statistics/options").then(o=>{if(active){setOptions(o);setHeadquarters([o.currentHeadquarters]);setExcludeFreeCancellations(o.excludeFreeCancellations);setRelatedCustomer(o.relatedCustomer);}}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[]);
async function calculate(e:FormEvent) {e.preventDefault();setBusy(true);setError("");setResult(null);try{setResult(await post<Result>("/statistics/query",{interval,comparisons,headquarters,dateBasis,state,period,splits,amount,payment,customerNumber,courierNumber,customerGroup:customerGroup?Number(customerGroup):null,courierGroup:courierGroup?Number(courierGroup):null,retrospective:bool(retrospective),permanent:bool(permanent),longhaul:bool(longhaul),timeFrom:timeFrom||null,timeTo:timeTo||null,excludeFreeCancellations,relatedCustomer}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
function addComparison(){const prior=(value:string)=>{const [year,month,day]=value.split("-").map(Number);return `${year-1}-${String(month).padStart(2,"0")}-${String(Math.min(day,new Date(year-1,month,0).getDate())).padStart(2,"0")}`;};setComparisons(v=>[...v,{from:prior(interval.from),to:prior(interval.to)}]);}
function download(){if(!result)return;const escape=(value:unknown)=>`"${(typeof value==="string"?value.replace(/^[\s]*[=+\-@]/,"'$&"):String(value??"")).replaceAll('"','""')}"`;const header=["Von","Bis","Zeitraum",...result.splits.map(s=>splitLabels[s]),"Aufträge","Auftragswert netto","Frachtvergütung netto","EAU netto","BSG %","Servicepreis","TSZ","Servicevergütung","KM","CO2e kg","Aufträge mit KM","Aufträge mit CO2e"];
const lines=[header,...result.periods.flatMap(p=>p.rows.map(r=>[p.interval.from,p.interval.to,r.period,...result.splits.map((_,i)=>r[`split${i}`]),r.orders,r.revenue,r.courierRevenue,r.courierRevenue==null?null:r.revenue-r.courierRevenue,r.courierRevenue==null||!r.revenue?null:(r.revenue-r.courierRevenue)/r.revenue*100,r.serviceRevenue,r.fuelMarkup,r.courierService,r.kilometers,r.co2eKg,r.kilometersOrders,r.co2eKgOrders]))];const blob=new Blob(["\uFEFF"+lines.map(row=>row.map(escape).join(";")).join("\r\n")],{type:"text/csv;charset=utf-8"});const url=URL.createObjectURL(blob);const link=document.createElement("a");link.href=url;link.download="votian-statistik.csv";link.click();URL.revokeObjectURL(url);}
return <div className="statistics"><section className="panel admin-panel"><h2>Zeitraumstatistik</h2><p>Auswertung aller passenden Bestandsaufträge im gewählten Zeitraum.</p>{error&&<div className="alert error" role="alert">{error}</div>}
{!options?(error?<p>Bitte die Statistik erneut über die Navigation öffnen.</p>:<LoaderCircle className="spin"/>):<form onSubmit={calculate}><fieldset disabled={busy}><div className="admin-toolbar"><Field label="Von"><input type="date" required value={interval.from} onChange={e=>setInterval(v=>({...v,from:e.target.value}))}/></Field><Field label="Bis"><input type="date" required value={interval.to} min={interval.from} onChange={e=>setInterval(v=>({...v,to:e.target.value}))}/></Field><Select label="Datumsgrundlage" value={dateBasis} onChange={setDateBasis} values={dateLabels}/><Select label="Zeitraster" value={period} onChange={setPeriod} values={periodLabels}/></div>
<div className="statistics-checks" aria-label="Niederlassungen">{options.headquarters.map(h=><label className="admin-check" key={h.id}><input type="checkbox" checked={headquarters.includes(h.id)} onChange={e=>setHeadquarters(v=>e.target.checked?[...v,h.id]:v.filter(id=>id!==h.id))}/>{h.label}</label>)}</div>
<div className="admin-toolbar"><Select label="Auftragsstatus" value={state} onChange={setState} values={{FINISHED:"Erledigte Aufträge",ALL:"Alle Status",EXPORTED:"Erledigt und exportiert"}}/><Select label="Beträge" value={amount} onChange={setAmount} values={{ALL:"Aufträge und Gutschriften",ORDERS:"Nur Aufträge (Betrag > 0)",CREDITS:"Nur Gutschriften (Betrag < 0)"}}/><Select label="Zahlungsweise" value={payment} onChange={setPayment} values={{ALL:"Alle",INVOICE:"Rechnung",CASH:"Barzahlung"}}/><button type="button" className="button" onClick={()=>setShowFilters(v=>!v)} aria-expanded={showFilters}>Weitere Filter</button></div>
{showFilters&&<div className="statistics-extra"><div className="admin-toolbar"><Field label="Kundennummer"><input maxLength={50} value={customerNumber} onChange={e=>setCustomerNumber(e.target.value)}/></Field><Field label="Transporteursnummer"><input maxLength={50} value={courierNumber} onChange={e=>setCourierNumber(e.target.value)}/></Field><Select label="Kundengruppe" value={customerGroup} onChange={setCustomerGroup} values={{"":"Alle",...Object.fromEntries(options.groups.map(g=>[g.id,g.label]))}}/><Select label="Transporteursgruppe" value={courierGroup} onChange={setCourierGroup} values={{"":"Alle",...Object.fromEntries(options.groups.map(g=>[g.id,g.label]))}}/></div><div className="admin-toolbar"><Select label="Nacherfassungen" value={retrospective} onChange={setRetrospective} values={{"":"Alle",true:"Nur Nacherfassungen",false:"Ohne Nacherfassungen"}}/><Select label="Daueraufträge" value={permanent} onChange={setPermanent} values={{"":"Alle",true:"Nur Daueraufträge",false:"Ohne Daueraufträge"}}/><Select label="Fernaufträge" value={longhaul} onChange={setLonghaul} values={{"":"Alle",true:"Nur Fernaufträge",false:"Ohne Fernaufträge"}}/><Field label="Uhrzeit von"><input type="time" value={timeFrom} onChange={e=>setTimeFrom(e.target.value)}/></Field><Field label="Uhrzeit bis"><input type="time" value={timeTo} onChange={e=>setTimeTo(e.target.value)}/></Field></div><div className="statistics-checks"><label className="admin-check"><input type="checkbox" checked={excludeFreeCancellations} onChange={e=>setExcludeFreeCancellations(e.target.checked)}/>Kostenlose Stornos ausschließen</label><label className="admin-check"><input type="checkbox" checked={relatedCustomer} onChange={e=>setRelatedCustomer(e.target.checked)}/>Zugeordneten Kunden statt Rechnungszahler verwenden</label></div></div>}
<h3>Aufteilung <small>(bis zu fünf)</small></h3><div className="statistics-checks">{Object.entries(splitLabels).map(([key,label])=><label className="admin-check" key={key}><input type="checkbox" checked={splits.includes(key)} disabled={!splits.includes(key)&&splits.length>=5} onChange={e=>setSplits(v=>e.target.checked?[...v,key]:v.filter(s=>s!==key))}/>{label}</label>)}</div>
{comparisons.map((v,i)=><div className="admin-toolbar" key={i}><Field label={`Vergleich ${i+1} von`}><input type="date" required value={v.from} onChange={e=>setComparisons(all=>all.map((x,n)=>n===i?{...x,from:e.target.value}:x))}/></Field><Field label={`Vergleich ${i+1} bis`}><input type="date" required value={v.to} min={v.from} onChange={e=>setComparisons(all=>all.map((x,n)=>n===i?{...x,to:e.target.value}:x))}/></Field><button className="icon-button" type="button" aria-label={`Vergleich ${i+1} entfernen`} onClick={()=>setComparisons(all=>all.filter((_,n)=>n!==i))}><X size={17}/></button></div>)}
<div className="admin-toolbar"><button type="button" className="button" disabled={comparisons.length>=5||!interval.from||!interval.to} onClick={addComparison}><Plus size={16}/>Vergleichszeitraum</button><button className="button primary" disabled={!headquarters.length}>{busy?<LoaderCircle className="spin" size={17}/>:<BarChart3 size={17}/>}Statistik berechnen</button></div></fieldset></form>}
</section>{result&&<><div className="statistics-result-heading"><p>{dateLabels[result.dateBasis]} · {periodLabels[result.period]} · Nettobeträge</p><button className="button" onClick={download}><ArrowDownToLine size={16}/>Statistik als CSV</button></div><section className="panel admin-panel"><h2>Zeiträume im Vergleich</h2><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Zeitraum</th><th>Aufträge</th><th>Auftragswert</th><th>Frachtvergütung</th><th>EAU</th><th>BSG</th><th>KM</th><th>CO₂e kg</th></tr></thead><tbody>{result.periods.map((p,i)=>{const r=p.totals;return <tr key={i}><td>{p.interval.from} – {p.interval.to}</td><td>{r.orders}</td><td>{money(r.revenue)}</td><td>{money(r.courierRevenue)}</td><td>{money(r.courierRevenue==null?null:r.revenue-r.courierRevenue)}</td><td>{r.courierRevenue==null||!r.revenue?"–":fmt((r.revenue-r.courierRevenue)/r.revenue*100)+" %"}</td><td>{fmt(r.kilometers)}</td><td>{fmt(r.co2eKg)}</td></tr>;})}</tbody></table></div></section>
{result.periods.map((p,i)=><section className="panel admin-panel" key={i}><h2>{i?`Vergleich ${i}`:"Auswertung"}: {p.interval.from} – {p.interval.to}</h2>{p.totals.missingCourierValues>0&&<p className="alert error">Bei {p.totals.missingCourierValues} Aufträgen ist keine eindeutige Frachtvergütung berechenbar. Die betroffenen Summen werden nicht als vollständige Werte ausgewiesen.</p>}<p className="admin-hint">KM: für {p.totals.kilometersOrders||0} von {p.totals.orders} Aufträgen hinterlegt. CO₂e: für {p.totals.co2eKgOrders||0} von {p.totals.orders} Aufträgen hinterlegt.</p><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Zeitraum</th>{result.splits.map(s=><th key={s}>{splitLabels[s]}</th>)}{["Aufträge","Auftragswert","Frachtvergütung","Servicepreis","TSZ","Servicevergütung","KM","CO₂e kg"].map(s=><th key={s}>{s}</th>)}</tr></thead><tbody>{p.rows.map((r,n)=><tr key={n}><td>{r.period}</td>{result.splits.map((_,j)=><td key={j}>{r[`split${j}`]}</td>)}<td>{r.orders}</td><td>{money(r.revenue)}</td><td>{money(r.courierRevenue)}</td><td>{money(r.serviceRevenue)}</td><td>{money(r.fuelMarkup)}</td><td>{money(r.courierService)}</td><td>{fmt(r.kilometers)}</td><td>{fmt(r.co2eKg)}</td></tr>)}</tbody></table>{!p.rows.length&&<p>Keine Aufträge für diese Auswahl gefunden.</p>}</div></section>)}
</>}</div>;
}
+85
View File
@@ -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>;
}
+10
View File
@@ -193,3 +193,13 @@ export async function exportCsv(params: URLSearchParams) {
link.click();
URL.revokeObjectURL(url);
}
export async function downloadBusinessFile(path: string, body: unknown, filename: string) {
const version = sessionVersion;
const response = await fetchResponse(path, { method: "POST", body: JSON.stringify(body) }, version);
const blob = await response.blob();
ensureCurrentSession(version);
const url = URL.createObjectURL(blob);
const link = document.createElement("a"); link.href = url; link.download = filename; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+220
View File
@@ -340,6 +340,7 @@ small {
background: #d89550;
}
.main-nav {
overflow-x: auto;
background: linear-gradient(90deg, #302779, #292168);
height: 54px;
display: flex;
@@ -1460,6 +1461,21 @@ main {
grid-template-columns: 1fr 1.8fr;
gap: 10px;
}
.entry-lookup { margin: 10px 0 18px; }
.entry-lookup .field { margin-bottom: 8px; }
.entry-lookup > select { width: 100%; font-size: 12px; }
.entry-selected { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: #f5f0f8; border: 1px solid #e8ddee; border-radius: 8px; }
.entry-selected small, .entry-selected strong { display: block; }
.entry-selected small { font-size: 10px; color: #98739c; margin-bottom: 5px; }
.entry-selected strong { font-size: 12px; font-weight: 500; }
.entry-address-search, .entry-recurrence { padding: 12px 14px; margin: 10px 0; background: #faf7fc; border-radius: 8px; }
.entry-checkbox, .entry-weekdays { display: flex; align-items: center; gap: 8px; font-size: 12px; margin: 8px 0 18px; }
.entry-weekdays { flex-wrap: wrap; gap: 18px; }
.entry-weekdays label { display: flex; align-items: center; gap: 6px; }
.entry-quote { border: 1px solid #e8ddee; border-radius: 8px; padding: 12px 16px; margin-top: 16px; }
.entry-quote table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; }
.entry-quote td, .entry-quote th { padding: 9px 5px; text-align: right; border-bottom: 1px solid #eee7f3; }
.entry-quote td:first-child, .entry-quote th:first-child { text-align: left; }
.form-section {
margin-bottom: 25px;
padding-bottom: 13px;
@@ -3045,3 +3061,207 @@ main {
width: auto;
}
}
.entry-copy, .stop-editor details, .form-section details { margin: 12px 0; }
.entry-copy summary, .stop-editor summary, .form-section summary { cursor: pointer; padding: 8px 0; font-weight: 600; }
.entry-filter-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 6px 16px; }
.entry-upload-frame { width: 100%; height: 500px; border: 1px solid var(--border); border-radius: 10px; }
.entry-service-row { align-items: end; margin-bottom: 10px; }
.entry-feature-row { padding: 16px; margin: 12px 0; border: 1px solid var(--border, #dce3e8); border-radius: 10px; }
.entry-map-panel { padding: 12px; border: 1px solid var(--border, #dce3e8); border-radius: 10px; margin-top: 8px; }
.entry-map-frame { width: 100%; height: min(60vh, 540px); min-height: 320px; border: 0; border-radius: 8px; }
.entry-shortcuts { margin-bottom: 16px; }
.entry-shortcut-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 8px 20px; font-size: 13px; }
.entry-shortcut-list > div { display: flex; gap: 12px; }
.entry-shortcut-list dt { min-width: 48px; }
.entry-shortcut-list dd { margin: 0; }
/* Administration follows the legacy workflow while keeping native, accessible controls. */
.administration { display:grid;grid-template-columns:210px minmax(0,1fr);gap:24px;align-items:start; }
.admin-navigation { display:flex;flex-direction:column;gap:5px;padding:8px;background:#fff;border:1px solid var(--line,#e3e8ed);border-radius:12px; }
.admin-navigation button { display:flex;align-items:center;gap:10px;text-align:left;padding:13px 12px;background:none;border:0;border-radius:7px;color:#536477;cursor:pointer;font:inherit; }
.admin-navigation button.active { color:#175b56;background:#e8f3f0;font-weight:650; }
.admin-panel { padding:28px;min-width:0; }
.admin-panel h2 { margin:0 0 8px; }
.admin-panel p { line-height:1.6; }
.admin-panel fieldset,.admin-dialog-body fieldset,.appointment-panel fieldset { border:0;padding:0;margin:0;min-width:0; }
.admin-title { display:flex;justify-content:space-between;gap:24px;align-items:start;margin-bottom:20px; }
.admin-title p,.admin-hint { color:#667789;font-size:13px; }
.admin-toolbar { display:flex;flex-wrap:wrap;align-items:end;gap:16px;margin:20px 0; }
.admin-toolbar .field { flex:1;min-width:120px;max-width:250px; }
.admin-toolbar .button { min-height:42px; }
.admin-copy { border-top:1px solid #e6ebef;padding-top:16px; }
.admin-table-wrap { width:100%;overflow:auto; }
.admin-table { width:100%;border-collapse:collapse;font-size:13px; }
.admin-table th { text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#667789;background:#f6f8fa;padding:12px; }
.admin-table td { border-bottom:1px solid #e8edf0;padding:10px 12px;vertical-align:middle; }
.admin-table input:not([type=checkbox]) { width:100%;min-width:130px;padding:9px 11px;background:#fff;border:1px solid #dce4e9;border-radius:6px;font:inherit; }
.admin-table input[type=number] { max-width:170px; }
.admin-table input:disabled { background:#f4f6f8;color:#667789; }
.admin-table input[type=checkbox],.admin-check input { width:17px;height:17px;accent-color:#176d63; }
.admin-locked { color:#7c8896; }
.admin-row-actions { display:flex;gap:8px;align-items:center;flex-wrap:wrap; }
.admin-dialog-body { padding:24px; }
.admin-dialog-body .admin-row-actions { margin-top:20px;justify-content:flex-end; }
.admin-check { display:flex;gap:10px;align-items:center;font-size:13px;margin:16px 0; }
.admin-success { border-left:3px solid #218273;background:#edf8f4;padding:10px 14px;color:#175f50; }
@media(max-width:900px) { .administration { grid-template-columns:1fr;gap:16px; }.admin-navigation { flex-direction:row;overflow:auto; }.admin-panel { padding:18px; }.admin-title { flex-wrap:wrap; } }
.statistics { display:grid;gap:22px; }
.statistics-checks { display:flex;gap:4px 24px;flex-wrap:wrap; }
.statistics-checks .admin-check { margin:7px 0; }
.statistics-extra { padding:4px 18px;background:#f6f8fa;border-radius:10px;margin:18px 0; }
.statistics h3 { margin:25px 0 10px;font-size:14px; }
.statistics h3 small { font-weight:400;color:#728292; }
.statistics-result-heading { display:flex;justify-content:space-between;align-items:center;gap:12px; }
.statistics .admin-table td { white-space:nowrap; }
/* Customer/courier reports */
.report-filters{display:grid;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr);gap:24px;margin:12px 0 20px}
.report-headquarters{display:flex;flex-wrap:wrap;gap:10px 18px;margin:10px 0}
.report-table{min-width:960px}.report-table td{vertical-align:top}.report-table small{display:block;color:var(--muted);margin-top:6px}
.report-table td:nth-child(4){min-width:240px;max-width:440px}.report-text{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;line-height:1.6}
.report-confidential{display:inline-flex;align-items:center;gap:6px;color:#7656a5;background:#f1ecfa;border-radius:6px;padding:4px 8px;margin-top:8px;font-size:12px}
.report-table .admin-row-actions{margin-top:12px;flex-wrap:nowrap}.report-pagination{justify-content:flex-end;align-items:center;margin-top:16px}
.report-empty{display:flex;align-items:center;gap:10px;padding:24px;color:var(--muted)}
.report-statistics{background:#f7f9fc;border:1px solid var(--line);border-radius:12px;padding:18px;margin:22px 0}.report-statistics h3{margin:0}
.report-warning{background:#fff8e6;border:1px solid #e6c16d;border-radius:10px;padding:14px;margin:12px 0}
@media(max-width:900px){.report-filters{grid-template-columns:1fr}.report-pagination{justify-content:space-between;flex-wrap:wrap}}
/* Vertrieb: compact calendar grids and shared appointment editor. */
.appointment-panel { padding: 24px; min-width: 0; }
.appointment-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0; }
.appointment-period { align-items: end; }
.appointment-filter-details { border: 1px solid var(--border, #dce3ed); border-radius: 10px; margin: 12px 0 22px; }
.appointment-filter-details summary { cursor: pointer; padding: 12px 16px; color: #4d5e72; font-weight: 600; }
.appointment-filters { padding: 0 16px 16px; }
.appointment-visibility { display: flex; flex-wrap: wrap; gap: 12px 22px; margin-top: 16px; }
.appointment-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
.appointment-participants { margin: 18px 0; }
.appointment-people { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); max-height: 230px; overflow: auto; gap: 8px; border: 1px solid #e0e5ed; padding: 12px; border-radius: 8px; }
.appointment-people small { display: block; font-size: 11px; color: #738092; margin-top: 3px; }
.appointment-agenda { display: grid; gap: 12px; }
.appointment-card { text-align: left; border: 1px solid #e1e6ee; border-left: 4px solid #6e7eb5; background: #fff; border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 8px; cursor: pointer; color: #25354b; min-width: 0; font: inherit; }
.appointment-card:hover { background: #f5f7fc; border-color: #9baed9; }
.appointment-card.monitor-1 { border-left-color: #d69a39; }
.appointment-card.monitor-2 { border-left-color: #398d73; }
.appointment-card strong { white-space: pre-wrap; overflow-wrap: anywhere; }
.appointment-card small, .appointment-card-time { color: #6b778b; font-size: 12px; }
.appointment-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
.appointment-tags span { padding: 4px 8px; border-radius: 6px; background: #f0f3f9; font-size: 12px; }
.appointment-empty { display: flex; align-items: center; gap: 8px; padding: 20px 8px; color: #768397; }
.appointment-week { display: grid; grid-template-columns: repeat(7, minmax(140px, 1fr)); gap: 8px; overflow-x: auto; padding-bottom: 12px; }
.appointment-week > section { min-width: 0; border: 1px solid #e2e7ef; border-radius: 8px; padding: 8px; }
.appointment-day-title { border: none; background: none; padding: 10px 5px; font: inherit; font-weight: 700; color: #354d76; cursor: pointer; text-align: left; }
.appointment-day-title:hover { color: #304fc0; text-decoration: underline; }
.appointment-agenda.compact .appointment-card { padding: 10px; font-size: 12px; }
.appointment-agenda.compact strong { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.appointment-month { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); border-top: 1px solid #e2e7ef; border-left: 1px solid #e2e7ef; }
.appointment-weekday { padding: 10px; font-weight: 600; text-align: center; background: #f4f6fa; font-size: 12px; }
.appointment-date, .appointment-calendar-gap { min-height: 112px; min-width: 0; border-bottom: 1px solid #e2e7ef; border-right: 1px solid #e2e7ef; padding: 4px; }
.appointment-calendar-gap { background: #fafbfc; }
.appointment-date.has-events { background: #f3f6ff; }
.appointment-date .appointment-day-title { width: 100%; display: flex; justify-content: space-between; }
.appointment-date small { background: #e0e8fa; border-radius: 10px; padding: 2px 6px; font-size: 10px; }
.appointment-month-event { width: 100%; border: none; padding: 5px; background: transparent; cursor: pointer; text-align: left; color: #30466a; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.appointment-month-event:hover { background: #e4ebfc; }
.appointment-year { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.appointment-month.mini .appointment-date, .appointment-month.mini .appointment-calendar-gap { min-height: 34px; padding: 0; }
.appointment-month.mini .appointment-weekday { padding: 5px 0; font-size: 10px; }
.appointment-month.mini .appointment-day-title { padding: 6px 3px; font-size: 11px; }
.appointment-month.mini .appointment-date small { padding: 1px 3px; font-size: 9px; }
.appointment-statistics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; align-items: start; }
@media (max-width: 950px) { .appointment-year { grid-template-columns: repeat(2, minmax(0, 1fr)); } .appointment-people { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .appointment-panel { padding: 14px; } .appointment-form-grid, .appointment-statistics, .appointment-year, .appointment-people { grid-template-columns: minmax(0, 1fr); } .appointment-date, .appointment-calendar-gap { min-height: 65px; } .appointment-month-event { font-size: 10px; } }
.crm-nav { margin-bottom: 16px; flex-wrap: wrap; }
.crm-panel { padding: 24px; }
.crm-contact-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 16px; margin: 18px 0; }
.crm-contact, .crm-motivation { padding: 20px; border: 1px solid var(--border, #dedee7); border-radius: 12px; background: var(--surface, #fff); min-width: 0; }
.crm-contact-list { display: grid; gap: 10px; margin-top: 18px; }
.crm-contact-list .crm-contact dl { display: flex; flex-wrap: wrap; gap: 12px; }
.crm-contact h3, .crm-motivation h3 { margin: 0 0 16px; }
.crm-contact small, .crm-motivation small { color: #707082; font-size: 12px; font-weight: 400; }
.crm-contact dl > div, .crm-motivation dl > div { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; margin: 8px 0; overflow-wrap: anywhere; }
.crm-panel dd, .crm-mail-preview { margin: 0; overflow-wrap: anywhere; }
.crm-panel dt { color: #64647a; }
.crm-table-scroll { overflow-x: auto; margin: 16px 0 28px; }
.crm-table-scroll table { width: 100%; border-collapse: collapse; }
.crm-table-scroll th, .crm-table-scroll td { padding: 12px; text-align: left; border-bottom: 1px solid #e7e7ee; }
.crm-table-scroll th { background: #f5f4fa; }
.crm-table-scroll td small { display: block; color: #777; }
.crm-motivation progress { display: block; width: 100%; height: 18px; margin: 16px 0; accent-color: #6d4ce8; }
.crm-motivation > strong { font-size: 24px; }
.crm-mail-preview { white-space: pre-wrap; padding: 20px; background: #f6f5fa; border-radius: 8px; font: inherit; }
.crm-panel fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.crm-contact-print { padding: 24px; }
.crm-contact-print dl > div { display: grid; grid-template-columns: 180px 1fr; gap: 20px; padding: 8px 0; break-inside: avoid; }
.crm-contact-print dd { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
@media print { body:has(.crm-contact-print) .modal-backdrop:not(:last-child) { display: none; } }
.message-text{white-space:pre-wrap;overflow-wrap:anywhere;max-width:65ch}.message-recipients{max-height:360px;overflow:auto;margin:12px 0;padding:8px;border:1px solid var(--border,#e2e6ed);border-radius:8px}.message-recipients .admin-check{display:flex;margin:6px 0}.message-lookup{margin:12px 0}.admin-table td small{display:block;color:var(--muted,#65738b);margin-top:6px}
.template-source{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre;tab-size:2;resize:vertical;width:100%;font-size:12px}
.newsletter-preview{border:1px solid #ddd;border-radius:8px;width:100%;height:60vh;background:white}.newsletter-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.newsletter-cards article{padding:16px}
/* Service tariff matrix keeps headings visible while scrolling large legacy catalogs. */
.service-price-wrap { max-height: 65vh; isolation: isolate; }
.service-price-table { width: max-content; min-width: 100%; }
.service-price-table th { max-width: 180px; min-width: 140px; white-space: normal; }
.service-price-table thead th { position: sticky; top: 0; z-index: 2; background: #edf2f8; }
.service-price-table th:first-child { position: sticky; left: 0; background: #f5f7fb; z-index: 1; }
.service-price-table thead th:first-child { z-index: 3; }
.service-price-table td { min-width: 155px; }
.service-price-table label { display: grid; gap: 3px; margin: 3px 0; }
.service-price-table label span { font-size: 11px; color: #53647a; }
.service-price-table input { width: 125px; text-align: right; }
.service-price-table .service-courier input { background: #eff8f5; }
.service-save { margin-top: 16px; }
.service-definitions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; }
.service-definitions ul { list-style: none; padding: 0; }
.service-definitions li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
.service-definitions li span { flex: 1; overflow-wrap: anywhere; }
.service-group { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
@media (max-width: 850px) { .service-definitions { grid-template-columns: 1fr; } }
.zone-links { display: flex; flex-wrap: wrap; gap: .6rem 1.3rem; margin: .7rem 0; }
.zone-links label { display: flex; align-items: center; gap: .4rem; }
.zone-pagination { margin-top: 1rem; }
.zone-manager h3 { margin: 1.4rem 0 .5rem; }
.business-section { padding: 18px 0; border-top: 1px solid var(--border, #e5e5ef); }
.business-section h3 { margin: 0 0 16px; font-size: 16px; }
.business-summary { display: flex; flex-wrap: wrap; gap: 24px; margin: 20px 0; padding: 20px; background: #f5f3ff; border-radius: 12px; }
.business-summary span { display: grid; gap: 6px; font-size: 13px; color: #625b75; }
.business-summary strong { font-size: 21px; color: #252037; }
.business-child-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.business-station { padding: 14px; margin: 10px 0; border: 1px solid #e5e5ef; border-radius: 10px; }
.business-station summary { cursor: pointer; font-weight: 600; }
.business-billing { padding: 24px; }
.business-billing .section-toolbar { padding: 0 0 20px; }
.business-billing .admin-row-actions { margin: 16px 0; }
.business-preline { white-space: pre-wrap; max-width: 520px; }
.business-billing fieldset { min-width: 0; }
.business-fieldset{border:0;padding:0;margin:0;min-width:0}
.master-address-lookup{margin:0 0 1rem;padding:.8rem;background:var(--surface-soft,#f5f5fa);border-radius:8px;max-width:650px}
.master-address-lookup select{width:100%;margin-top:.5rem}
.maps-attribution{display:block;text-align:right;color:#555;margin-top:.3rem;font-family:Arial,sans-serif}
.customer-center-filters{padding:0 1.5rem 1rem}
.report-form-layout { overflow-x: auto; padding: 16px; background: #fff; color: #182235; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 18px; }
.report-form-layout table { border-collapse: collapse; }
.report-form-layout td, .report-form-layout th { padding: 5px; }
.report-form-layout input:not([type=checkbox]), .report-form-layout textarea { max-width: 100%; min-width: 80px; padding: 6px 8px; border: 1px solid #a9b5c6; border-radius: 5px; background: #f5f8ff; color: #182235; }
.report-form-layout textarea { display: block; width: 100%; }
.report-form-layout input[type=checkbox] { width: 18px; height: 18px; }
.report-inline-field { display: inline-block; max-width: 100%; }
.report-form-layout details { margin-top: 20px; }
.customer-services-selected { padding-left: 24px; }
.customer-services-selected li { margin-bottom: 8px; }
.customer-services-selected li span { display: inline-block; min-width: 100px; margin-right: 8px; }
.customer-services-available { display: grid; gap: 8px; max-height: 340px; overflow-y: auto; padding: 8px 0; }
+8
View File
@@ -58,6 +58,8 @@ export interface Order {
history: History[];
}
export interface Customer {
costCenterId?: number | null;
costCenterName?: string;
id: number;
version: number;
number: string;
@@ -69,6 +71,7 @@ export interface Customer {
phone: string;
email: string;
paymentDays: number | null;
paymentTermLabel?: string;
active: boolean;
}
export interface Courier {
@@ -100,6 +103,11 @@ export interface User {
roles: string[];
}
export interface Capabilities {
createOrders?: boolean;
readStatistics?: boolean;
readAppointments?: boolean;
readMessages?: boolean;
manageForms?: boolean;
readOrders: boolean;
readCustomers: boolean;
readCouriers: boolean;
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, type RefObject } from "react";
/** Only the active entry dialog owns these keys. AltGr, repeats and child dialogs are excluded. */
export function useOrderEntryShortcuts(form: RefObject<HTMLFormElement | null>, blocked: boolean, actions: Record<string, () => void>) {
useEffect(() => {
const handle = (event: KeyboardEvent) => {
const root = form.current;
if (!root || blocked || !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.repeat || event.isComposing || event.getModifierState("AltGraph")) return;
const dialogs = document.querySelectorAll('[role="dialog"]');
if (dialogs.length && !dialogs[dialogs.length - 1].contains(root)) return;
const key = event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
if (actions[key]) { event.preventDefault(); actions[key](); return; }
let scope: Element = root, targetKey = key;
const stationKeys: Record<string, [string, string]> = { g: ["pickup", "address"], h: ["delivery", "address"], v: ["pickup", "company"], n: ["delivery", "company"], o: ["pickup", "note"], p: ["delivery", "note"], u: ["pickup", "reference"], w: ["delivery", "reference"] };
if (stationKeys[key]) {
const [side, field] = stationKeys[key];
const stations = Array.from(root.querySelectorAll(`[data-station-type="${side === "pickup" ? "PICKUP" : "DELIVERY"}"], [data-station-type="BOTH"]`));
scope = (side === "pickup" ? stations[0] : stations[stations.length - 1]) ?? root;
targetKey = field;
}
const element = scope.querySelector<HTMLElement>(`[data-entry-key="${targetKey}"]`);
if (!element || element.matches(":disabled")) return;
event.preventDefault();
let parent = element.parentElement;
while (parent && parent !== root) { if (parent instanceof HTMLDetailsElement) parent.open = true; parent = parent.parentElement; }
element.scrollIntoView({ block: "nearest" }); element.focus();
if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement && element.type === "checkbox") element.click();
else if (element.tagName === "SUMMARY") (element.parentElement as HTMLDetailsElement).open = true;
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [form, blocked, actions]);
}