Erweitere VoTiAn NG um Fachfunktionen und verbessere die Listenoberfläche
Ergänze Auftragsbearbeitung, Disposition, Stammdaten, Kunden- und Transporteurportale, Lagerverwaltung, Abrechnung, Exporte und Statistik. Füge Kontoeinstellungen, Mitteilungen, Automatikdienste und Betriebsstatus sowie die zugehörigen Tests und PHP-Abgleichsdokumentation hinzu. Vergrößere die persönlichen Auftragslisten auf einheitliche Höhen und richte Statusanzeigen sowie die Ranking-Vorschau-Steuerelemente bündig aus. Validierung: 44 Frontendtests und Produktionsbuild erfolgreich. Backend funktional unverändert gegenüber dem vollständigen Lauf mit 1.624 erfolgreichen und drei übersprungenen Tests. Layout im Browser und lokale Bereitstellung geprüft.
This commit is contained in:
1 parent
363bc27c26
commit
781a2b82dc
1165 files changed
+74986
-1028
No files matched your search
Generated
+25
@@ -8,11 +8,13 @@
|
||||
"name": "votian-ng-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
@@ -310,6 +312,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
@@ -410,6 +429,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type Status={revision:string;active:boolean;canSetup:boolean;canReset:boolean;selfDeactivationBlocked:boolean;self:boolean;account:string};
|
||||
type Setup={setupId:string;secret:string;qrDataUrl:string;expiresAt:string;account:string};
|
||||
export function AccountTwoFactorDialog({employee,onClose,onDirtyChange,basePath,onReauthenticate}:{employee?:number;onClose:()=>void;onDirtyChange:(v:boolean)=>void;basePath?:string;onReauthenticate?:()=>Promise<void>}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=basePath||(employee?`/administration/employees/${employee}/two-factor`:'/account/two-factor');
|
||||
const [data,setData]=useState<Status|null>(null),[setup,setSetup]=useState<Setup|null>(null),[password,setPassword]=useState(''),[code,setCode]=useState(''),[account,setAccount]=useState('');
|
||||
const [busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[done,setDone]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<{action:'setup'|'activate'|'reset';path:string;body:unknown}|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!done&&(!!setup||!!password||!!code||!!account);
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
useEffect(()=>{const c=new AbortController();request<Status>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base]);
|
||||
async function abandon(){const previous=setup;setSetup(null);setPassword('');setCode('');setAccount('');pending.current=null;if(previous)await post(base+'/cancel',{setupId:previous.setupId}).catch(()=>{});}
|
||||
async function close(){if(sending.current||uncertain)return;if(!dirty||await discard('Einrichtung und ungespeicherte Zwei-Faktor-Angaben verwerfen?')){await abandon();if(done&&data?.self&&onReauthenticate){try{await onReauthenticate();}catch(e){setError((e as Error).message);return;}}onClose();}}
|
||||
async function restart(){if(sending.current||uncertain||dirty&&!await discard('Einrichtung und ungespeicherte Zwei-Faktor-Angaben verwerfen?'))return;sending.current=true;setBusy(true);setError('');try{await abandon();setData(await request<Status>(base));setStale(false);setNotice('');}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function submit(e:FormEvent){e.preventDefault();if(!data||sending.current||stale||done)return;
|
||||
if(!pending.current){const action=setup?'activate':data.active?'reset':'setup';const body=action==='setup'?{requestId:crypto.randomUUID(),revision:data.revision,password}:action==='activate'?{requestId:crypto.randomUUID(),setupId:setup!.setupId,code}:{requestId:crypto.randomUUID(),revision:data.revision,password,code,account};pending.current={action,path:base+'/'+action,body};}
|
||||
const p=pending.current;sending.current=true;setBusy(true);setError('');try{const result=await post<Setup>(p.path,p.body);setUncertain(false);pending.current=null;setPassword('');setCode('');setAccount('');if(p.action==='setup')setSetup(result);else{setSetup(null);setDone(true);setNotice(p.action==='activate'?'Zwei-Faktor-Anmeldung aktiviert. Bitte mit dem nächsten App-Code erneut anmelden.':data.self?'Zwei-Faktor-Anmeldung deaktiviert. Bitte erneut anmelden.':'Zwei-Faktor-Anmeldung zurückgesetzt. Die betroffene Person kann ihre Authenticator-App erneut einrichten.');}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setCode('');setStale(e instanceof ApiError&&e.status===409);}}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
const locked=busy||uncertain||stale||done,allowed=data&&(setup||data.active?setup||data.canReset:data.canSetup);
|
||||
return <><Modal title="Zwei-Faktor-Anmeldung" onClose={()=>void close()}><form className="employee-editor" onSubmit={submit}><div className="admin-dialog-body modal-body">
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{notice&&<p role="status" className="admin-success">{notice}</p>}{!data&&!error&&<p>Kontoeinstellungen werden geladen …</p>}
|
||||
{data&&<><p>Konto: <strong>{data.account}</strong> · {done?'Änderung gespeichert':data.active?'Zwei-Faktor-Anmeldung aktiv':'Zwei-Faktor-Anmeldung nicht aktiviert'}</p>
|
||||
{!done&&<>{setup?<><p>Öffnen Sie Ihre Authenticator-App, fügen Sie ein neues Konto hinzu und scannen Sie diesen QR-Code. Bestätigen Sie anschließend den angezeigten Code.</p><div className="account-qr"><img src={setup.qrDataUrl} alt="QR-Code zum Koppeln der Authenticator-App"/><details><summary>Schlüssel manuell eingeben</summary><code>{setup.secret}</code></details></div><p className="form-hint">Die Einrichtung ist zehn Minuten gültig. Aktiviert wird sie erst nach Bestätigung des Codes.</p></>:data.active?<p>{data.canReset?(data.self?'Zum Deaktivieren bestätigen Sie Ihr Passwort, den aktuellen App-Code und Ihren Anmeldenamen.':'Beim Zurücksetzen wird die bisherige Authenticator-Verknüpfung aufgehoben. Bestätigen Sie mit Ihrem eigenen Passwort und dem Anmeldenamen des betroffenen Kontos.'):(data.selfDeactivationBlocked?'Die eigene Deaktivierung ist gesperrt. Bitte wenden Sie sich an die zuständige Verwaltung.':'Sie dürfen die Zwei-Faktor-Anmeldung dieses Kontos nicht zurücksetzen.')}</p>:<p>{data.canSetup?'Richten Sie eine Authenticator-App als zusätzliche Bestätigung bei der Anmeldung ein. Bitte bestätigen Sie zunächst Ihr aktuelles Passwort.':'Die Einrichtung ist für dieses Konto hier nicht freigegeben.'}</p>}
|
||||
{allowed&&<fieldset disabled={locked}><legend>{setup?'App-Code bestätigen':data.active?'Rücksetzung bestätigen':'Einrichtung beginnen'}</legend>{!setup&&<Field label="Ihr aktuelles Passwort"><input type="password" required autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)}/></Field>}{(setup||data.active&&data.self)&&<Field label="Aktueller App-Code"><input required inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value.replace(/[^0-9]/g,'').slice(0,6))}/></Field>}{data.active&&<Field label="Anmeldenamen zur Bestätigung eingeben"><input required autoComplete="off" value={account} onChange={e=>setAccount(e.target.value)}/></Field>}</fieldset>}</>}
|
||||
</>}{uncertain&&<p className="alert info">Die Antwort fehlt. Bitte denselben Vorgang erneut abrufen. Eingaben bleiben bis zur Klärung gesperrt.</p>}{stale&&<p>Die Kontoeinstellungen haben sich geändert. Bitte den aktuellen Stand laden und erneut beginnen.</p>}
|
||||
</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done&&data?.self?'Weiter zur Anmeldung':uiText("Schließen")}</button>{!done&&<button type="button" className="button" disabled={busy||uncertain} onClick={()=>void restart()}>{setup?'Einrichtung neu beginnen':'Aktuellen Stand laden'}</button>}{!done&&allowed&&<button className="button primary" disabled={busy||stale||(!uncertain&&(data?.active?account!==data.account||!password||data.self&&code.length!==6:setup?code.length!==6:!password))}>{uncertain?'Ergebnis erneut abrufen':setup?'2FA aktivieren':data?.active?(data.self?'2FA deaktivieren':'2FA zurücksetzen'):'QR-Code erstellen'}</button>}</div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
import {cashMoney} from './Cashflow';
|
||||
type Input={hours:number;mode:number};
|
||||
type State={hours:string;child:number;childNet:number;choices:{id:number;label:string}[];canEdit:boolean;blocked:string};
|
||||
type Preview={revision:string;input:Input;hours:number;child:number;customerRate:number;courierRate:number;customerCategory:number;courierCategory:number;net:number;gross:number;courierNet:number;mode:number;modeLabel:string;intendedMode:string;fractionalCalculator:boolean};
|
||||
type Command={requestId:string;revision:string;input:Input;confirmed:boolean};
|
||||
export function AddedInstallation({id,onClose,onSaved}:{id:number;onClose:()=>void;onSaved:()=>void}){
|
||||
const [data,setData]=useState<State|null>(null),[hours,setHours]=useState(''),[mode,setMode]=useState(''),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[result,setResult]=useState<number|null>(null);const active=useRef(false),pending=useRef<Command|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();request<State>(`/orders/${id}/added-installation`,{signal:c.signal}).then(d=>{if(!c.signal.aborted){setData(d);setHours(d.hours.replace(',','.'));}}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[id]);
|
||||
const locked=busy||uncertain||stale||result!==null;function reset(){setPreview(null);setConfirmed(false);pending.current=null;}
|
||||
async function prepare(){if(active.current||locked||!data?.canEdit)return;active.current=true;setBusy(true);setError('');reset();try{setPreview(await post<Preview>(`/orders/${id}/added-installation/preview`,{hours:Number(hours),mode:Number(mode)}));}catch(e){setError((e as Error).message);}finally{active.current=false;setBusy(false);}}
|
||||
async function save(){if(active.current||!preview||!confirmed||stale||result!==null)return;active.current=true;setBusy(true);setError('');if(!pending.current)pending.current={requestId:crypto.randomUUID(),revision:preview.revision,input:preview.input,confirmed:true};try{const r=await post<{child:number}>(`/orders/${id}/added-installation`,pending.current);setUncertain(false);setResult(r.child);onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{active.current=false;setBusy(false);}}
|
||||
return <Modal title={`Zusätzliche Montagezeit · Auftrag #${id}`} wide onClose={()=>{if(!active.current&&!uncertain)onClose();}}><div className="modal-body admin-dialog-body">{error&&<p className="alert error" role="alert">{error}</p>}{data&&<><p>{data.child?`Zugehöriger Zusatzauftrag #${data.child} · bisher ${cashMoney(data.childNet)} netto`:'Beim Speichern wird ein zugehöriger Zusatzauftrag mit eigener Zahlung angelegt.'}</p>{data.blocked&&<p className="alert info">{data.blocked}</p>}<form onSubmit={e=>{e.preventDefault();void prepare();}}><fieldset className="business-fieldset" disabled={locked||!data.canEdit}><div className="form-grid cols-2"><Field label="Zusätzliche Montagestunden"><input type="number" required min={0} max={9999.99} step={0.01} value={hours} onChange={e=>{setHours(e.target.value);reset();}}/></Field><Field label="Montage-Zahlart"><select required value={mode} onChange={e=>{setMode(e.target.value);reset();}}><option value="">Bitte wählen</option>{data.choices.map(c=><option value={c.id} key={c.id}>{c.label}</option>)}</select></Field></div><p>Die hinterlegten Kunden- und Kurier-Stundensätze einschließlich laufender Preisaktionen werden neu ausgewertet. Die Vorschau zeigt die Änderung am Zusatzauftrag und seiner Zahlung.</p><button className="button primary">Montageänderung prüfen</button></fieldset></form></>}
|
||||
{preview&&<section className="business-section"><h3>{preview.child?`Zusatzauftrag #${preview.child} ändern`:'Neuen Zusatzauftrag anlegen'}</h3><div className="business-summary"><span>Stunden <strong>{preview.hours.toLocaleString('de-DE')}</strong></span><span>Kundenpreis netto <strong>{cashMoney(preview.net)}</strong></span><span>Zahlung brutto <strong>{cashMoney(preview.gross)}</strong></span><span>Frachtvergütung netto <strong>{cashMoney(preview.courierNet)}</strong></span></div><p>Kunden-Stundensatz brutto: {cashMoney(preview.customerRate)} · Preiskategorie {preview.customerCategory}<br/>Kurier-Stundensatz brutto: {cashMoney(preview.courierRate)} · Preiskategorie {preview.courierCategory}</p><p>Zahlart: {preview.modeLabel}{preview.intendedMode?` · vorgesehene Online-Zahlart: ${data?.choices.find(c=>String(c.id)===preview.intendedMode)?.label||preview.intendedMode}`:''}</p>{preview.fractionalCalculator&&<p className="alert info">Die bisherigen Kalkulatorpositionen speichern Mengen nur ganzzahlig. Die genaue Stundenzahl bleibt im Montagefeld erhalten; für die Abrechnung ist der angezeigte Gesamtbetrag maßgeblich.</p>}<label className="admin-check"><input type="checkbox" disabled={locked} checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Stunden, Zusatzauftrag, Preise und Zahlungsbuchung geprüft</label></section>}
|
||||
{stale&&<p>Bitte den Dialog schließen und den Auftrag neu laden.</p>}{uncertain&&<p role="status">Die Antwort fehlt. Erneutes Prüfen verwendet dieselbe Vorgangskennung und legt keinen zweiten Zusatzauftrag an.</p>}{result!==null&&<p className="alert success" role="status">Montagezeit und Zusatzauftrag #{result} mit Zahlungsbuchung gespeichert.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={onClose}>Schließen</button>{preview&&result===null&&<button className="button primary" disabled={busy||stale||!confirmed} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Zusatzmontage speichern'}</button>}</div></Modal>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError} from './api';
|
||||
@@ -9,6 +10,8 @@ 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 uiText=useUiText();
|
||||
|
||||
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]);
|
||||
@@ -16,9 +19,9 @@ export function AddressMappings({kind,onDirty}:{kind:Kind;onDirty:(v:boolean)=>v
|
||||
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>
|
||||
<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">{uiText("Suchen")}</button><button className="button" type="button" onClick={()=>{setDraft(blank);setQuery(blank);setPage(0);}}>{uiText("Felder zurücksetzen")}</button></div></fieldset></form>
|
||||
<div className="admin-toolbar"><span>{total} Zuordnungen · Seite {page+1} {uiText(" 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'?uiText("Bereich"):'Abrechnungs-PLZ'}</th><th>{uiText("Straße")}</th><th>Von</th><th>Bis</th><th>Modus</th><th>{uiText("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}>{uiText("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':uiText("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}){
|
||||
@@ -26,11 +29,13 @@ function AddressFields({kind,value,setValue,search=false}:{kind:Kind;value:Draft
|
||||
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 uiText=useUiText();
|
||||
|
||||
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}</>;
|
||||
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':uiText("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]);
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import {OperationsMonitoring} from './OperationsMonitoring';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {WarehouseUsers} from './WarehouseUsers';
|
||||
import type {User} from './types';
|
||||
import type {WorkspaceEntity} from './WorkspaceHeadquarters';
|
||||
import {HeadquartersSettings} from './HeadquartersSettings';
|
||||
import {PdaLocatingSettings} from './PdaLocatingSettings';
|
||||
import {EmployeeAdministration} from './EmployeeAdministration';
|
||||
import {WarehouseAdministration} from './WarehouseAdministration';
|
||||
import {ArticleAdministration} from './ArticleAdministration';
|
||||
import {VehicleDisposition} from './VehicleDisposition';
|
||||
import { lazy, Suspense, createContext, useContext, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { CalendarDays, Clock3, FolderTree, FileText, Info, LoaderCircle, Plus, Save, Trash2 } from "lucide-react";
|
||||
const FleetMap = lazy(() => import("./FleetMap").then(m=>({default:m.FleetMap})));
|
||||
import { ZonePrices } from "./ZonePrices";
|
||||
import { ServicePrices } from "./ServicePrices";
|
||||
import { AddressMappings } from "./AddressMappings";
|
||||
import { Filters } from "./Filters";
|
||||
import { Evaluations } from "./Newsletters";
|
||||
import { ExtrasStatistics } from "./ExtrasStatistics";
|
||||
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;
|
||||
type Config = { operations:boolean; warehouseUsers:boolean; warehouses:boolean; articles:boolean; headquartersSettings:boolean; employees:boolean; pdaSettings:boolean; vehicleDisposition:boolean; fleetMap:boolean; extras:boolean; 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 = "operations" | "warehouseUsers" | "warehouses" | "articles" | "headquartersSettings" | "pdaSettings" | "employees" | "vehicleDisposition" | "fleetMap" | "extras" | "postalPrices" | "postalDiscounts" | "areaPrices" | "areaDiscounts" | "servicePrices" | "serviceDiscounts" | "postalAddresses" | "areaAddresses" | "filters" | "evaluations" | "customerReports" | "courierReports" | "holidays" | "travelTimes" | "groups" | "imprint";
|
||||
const sections = [{id:"warehouseUsers",label:"Lagermitarbeiter",icon:FolderTree},{id:"warehouses",label:"Lagerorte",icon:FolderTree},{id:"articles",label:"Artikel",icon:FolderTree},{id:"headquartersSettings",label:"Niederlassungen",icon:FolderTree},{id:"employees",label:"Mitarbeiter",icon:FolderTree},{id:"pdaSettings",label:"PDA-Ortung",icon:Clock3},{id:"vehicleDisposition",label:"Fahrzeugdisposition",icon:CalendarDays},{id:"fleetMap",label:"Kartenübersicht",icon:FolderTree},{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:"extras",label:"Kessel Buntes",icon:FileText}, {id:"operations",label:"Betriebsstatus",icon:Clock3}, { 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>(()=>{});
|
||||
@@ -28,7 +41,9 @@ function useResource<T>(path: string, refresh: number) {
|
||||
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 }) {
|
||||
export function Administration({ refresh,onDirtyChange,workspaceHeadquarters,onWorkspaceChanged }: { refresh: number;onDirtyChange:(dirty:boolean)=>void;workspaceHeadquarters:number;onWorkspaceChanged:(user:User,target:WorkspaceEntity)=>void }) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
const [dirty,setDirty]=useState(false);
|
||||
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
|
||||
@@ -37,7 +52,17 @@ export function Administration({ refresh,onDirtyChange }: { refresh: number;onDi
|
||||
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}>
|
||||
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}/>{uiText(s.label)}</button>)}</aside><section className="panel admin-panel" key={active}>
|
||||
{active==="operations"&&<OperationsMonitoring refresh={refresh}/>}
|
||||
{active==="warehouseUsers"&&<WarehouseUsers refresh={refresh} onDirtyChange={setDirty}/> }
|
||||
{active==="warehouses"&&<WarehouseAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{active==="articles"&&<ArticleAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{active==="headquartersSettings"&&<HeadquartersSettings refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{active==="pdaSettings"&&<PdaLocatingSettings refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{active==="employees"&&<EmployeeAdministration refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{active==="vehicleDisposition"&&<VehicleDisposition refresh={refresh} onDirtyChange={setDirty} workspaceHeadquarters={workspaceHeadquarters} onWorkspaceChanged={onWorkspaceChanged}/>}
|
||||
{active==="fleetMap"&&<Suspense fallback={<p>Karte wird geladen …</p>}><FleetMap refresh={refresh} onDirtyChange={setDirty}/></Suspense>}
|
||||
{active==="extras"&&<ExtrasStatistics refresh={refresh} onDirtyChange={setDirty}/>}
|
||||
{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}/>}
|
||||
@@ -53,12 +78,14 @@ export function Administration({ refresh,onDirtyChange }: { refresh: number;onDi
|
||||
{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></>}
|
||||
{active==="imprint"&&<><h2>{uiText("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>{uiText("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 uiText=useUiText("admin/menu.php");
|
||||
|
||||
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);
|
||||
@@ -81,9 +108,9 @@ function Holidays({refresh}:{refresh:number}) {
|
||||
}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)=>{
|
||||
return <><div className="admin-title"><div><h2>{uiText("Feiertage")}</h2><p>Jahreskalender und Übernahme aus einer Vorlage oder einem Vorjahr.</p></div><strong>{draft.length} {uiText(" 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}/>{uiText("Ä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>{uiText("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></>}
|
||||
@@ -91,6 +118,8 @@ function Holidays({refresh}:{refresh:number}) {
|
||||
}
|
||||
type TravelRow={id:number;postalCode:string;country:string;minutes:number|null;revision:string};
|
||||
function TravelTimes({refresh}:{refresh:number}) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
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]);
|
||||
@@ -99,12 +128,14 @@ function TravelTimes({refresh}:{refresh:number}) {
|
||||
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}</>;
|
||||
return <><h2>{uiText("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>{uiText("PLZ")}</th><th>{uiText("Land")}</th><th>Zeit in Minuten</th><th>{uiText("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}/>{uiText("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 uiText=useUiText("admin/menu.php");
|
||||
|
||||
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);
|
||||
@@ -121,27 +152,31 @@ function Groups({refresh,config}:{refresh:number;config:Config}) {
|
||||
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}/>
|
||||
return <><div className="admin-title"><div><h2>{uiText("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>}
|
||||
{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)}>{uiText("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 uiText=useUiText("admin/menu.php");
|
||||
|
||||
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}</>;
|
||||
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={uiText("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">{uiText("Speichern")}</button></div></fieldset></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
function GroupMembers({group,headquarters,kinds,onClose}:{group:Group;headquarters:number;kinds:Option[];onClose:()=>void}) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
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>;
|
||||
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">{uiText("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>{uiText("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":uiText("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>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ErrorMessage,uncertainWrite} from './BusinessForms';
|
||||
type Source={folder:string;name:string;revision:string};
|
||||
type Options={customer:string;payer:number;date:string;maxRows:number;maxNew:number};
|
||||
type Address={name:string;street:string;postal:string;city:string;country:string;contact:string;phone:string;signer:string};
|
||||
type Shipment={line:number;transaction:string;sender:Address;recipient:Address;remark:string};
|
||||
type Preview={revision:string;date:string;sender:Address|null;shipments:Shipment[];existing:Shipment[];renamed:{jb_id:number;jb_tourname:string}[]};
|
||||
type Command={requestId:string;csv:string;date:string;revision:string;replaceNames:boolean};
|
||||
type Result={jobs:{id:number;name:string;stations:number}[];repeated:boolean};
|
||||
const columns=['Transaktionscode','Transaktions-ID','Absender','Absender-ID','Absenderkontakt','Absenderanschrift 1','Absenderanschrift 2','Absenderort','Absender-PLZ','Kundennummer Absender','Absenderland','Absendertelefon','Absenderunterschrift','Empfänger','Empfängerkontakt','Empfängeranschrift 1','Empfängeranschrift 2','Empfängerort','Empfängerland (PHP: empbsp)','Empfänger-PLZ','Empfängertelefon','Kundennummer Frachtzahler','Gewichtseinheit comuw','Frachtzahler','Versanddatum','Referenz','Pakethöhe','Paketbreite','Paketlänge','Währung','Gewichtseinheit','Warengewicht','Produktbeschreibung','Rechnungs-Flag','Packstücke','Dokumentensendung','Verpackung','Serviceart','Empfängermail','Transaktionsende','Paketnummer','Gewünschtes Zustelldatum','Zustelldatum','Zustellzeit','Kuriernummer','Letzte Änderung'];
|
||||
function AddressView({value:a}:{value:Address}){return <>{a.name}<br/>{a.street}, {a.postal} {a.city} ({a.country})<br/>{a.contact} {a.phone}</>;}
|
||||
export function AllergoImport({id,source,onClose}:{id:number;source?:Source;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/allergo-import/${id}`;const [options,setOptions]=useState<Options|null>(null),[csv,setCsv]=useState(''),[filename,setFilename]=useState(''),[encoding,setEncoding]=useState('utf-8'),[replaceNames,setReplaceNames]=useState(false),[preview,setPreview]=useState<Preview|null>(null),[result,setResult]=useState<Result|null>(null),[busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[error,setError]=useState('');
|
||||
const pending=useRef<Command|null>(null),file=useRef<File|null>(null);const [discard,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;async function load(){try{const o=await request<Options>(base);if(active)setOptions(o);if(source){const q=new URLSearchParams({...source,format:'ALLERGO'});const s=await request<{csv:string;filename:string;encoding:string}>(`/master-documents/customers/${id}/import-source?${q}`);if(active){setCsv(s.csv);setFilename(s.filename);setEncoding(s.encoding==='UTF-8'?'utf-8':'windows-1252');}}}catch(e){if(active)setError((e as Error).message);}finally{if(active)setBusy(false);}}void load();return()=>{active=false;};},[id]);
|
||||
function invalidate(){setPreview(null);setResult(null);setReplaceNames(false);pending.current=null;setError('');}
|
||||
async function read(f:File|null,charset:string){invalidate();file.current=f;if(!f)return;setBusy(true);try{if(f.size>1_000_000)throw new Error('Die Datei darf höchstens 1 MB groß sein.');setCsv(new TextDecoder(charset,{fatal:true}).decode(await f.arrayBuffer()));setFilename(f.name);}catch(e){setCsv('');setFilename('');setError(e instanceof TypeError?'Die Datei passt nicht zum Zeichensatz. Bei älteren Dateien Windows-1252 wählen.':(e as Error).message);}finally{setBusy(false);}}
|
||||
async function prepare(){if(!options)return;setBusy(true);invalidate();try{const c:Command={requestId:crypto.randomUUID(),csv,date:options.date,revision:'',replaceNames:false};const p=await request<Preview>(`${base}/preview`,{method:'POST',body:JSON.stringify(c)});setPreview(p);pending.current={...c,revision:p.revision};}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!pending.current)return;setBusy(true);setError('');if(!uncertain)pending.current={...pending.current,replaceNames};try{const r=await request<Result>(base,{method:'POST',body:JSON.stringify(pending.current)});setResult(r);pending.current=null;setUncertain(false);setPreview(null);setCsv('');setFilename('');setReplaceNames(false);}catch(e){setError((e as Error).message);const unclear=uncertainWrite(e);setUncertain(unclear);if(!unclear){setPreview(null);pending.current=null;setReplaceNames(false);}}finally{setBusy(false);}}
|
||||
async function close(){if(busy||uncertain)return;if(!csv||await discard('Den noch nicht übernommenen ALLERGO-Import verwerfen?'))onClose();}
|
||||
return <><Modal wide title="ALLERGO-Sendungen importieren" onClose={()=>void close()}><div className="modal-body admin-dialog-body"><ErrorMessage message={error}/>{options&&<><p><strong>{options.customer}</strong> · Hauptkostenstelle {options.payer} · {options.date.split('-').reverse().join('.')} um 16:00 Uhr</p><p>Ein Auftrag „ALLERGO“ mit dem gemeinsamen Absender als erster, bereits erledigter Station und den neuen Empfängern in Dateireihenfolge. Absenderunterschrift wird als Name übernommen. Status „Disposition“, keine Kurierzuweisung, Rechnung, Preise 0 €, Steuerkennung 4/V.</p><details><summary>CSV-Aufbau und Importregeln</summary><p>Semikolon-CSV mit Kopfzeile und 46 Spalten, maximal 1 MB/{options.maxRows} Zeilen. Höchstens {options.maxNew} neue Sendungen je Auftrag; unterschiedliche Absender bitte in getrennten Dateien importieren. Transaktionskennungen verhindern erneuten Import.</p><ol>{columns.map(c=><li key={c}>{c}</li>)}</ol><p>Anschriften 1 und 2 werden zusammengeführt; Länder müssen zweistellig angegeben sein. Paketdaten, Referenz und Versandinformationen werden als Bemerkung übernommen. Wie im aktiven Import erzeugen sie keine Artikelbuchungen. Gewünschte Zustelltermine, Warnzeit, Empfängermail und Kuriernummer lösen keine Terminplanung, Nachricht oder Zuweisung aus.</p></details><fieldset className="business-fieldset" disabled={busy||uncertain}><Field label="Zeichensatz"><select value={encoding} onChange={e=>{setEncoding(e.target.value);if(file.current)void read(file.current,e.target.value);}}><option value="utf-8">UTF-8</option><option value="windows-1252">Windows-1252</option></select></Field><Field label="Sendungsdatei"><input type="file" accept=".csv,.txt,text/csv,text/plain" onChange={e=>void read(e.target.files?.[0]??null,encoding)}/></Field>{filename&&<p>{filename}</p>}<Field label="ALLERGO-CSV"><textarea rows={6} maxLength={1_000_000} spellCheck={false} value={csv} onChange={e=>{setCsv(e.target.value);file.current=null;setFilename('');invalidate();}}/></Field></fieldset></>}
|
||||
{preview&&<section className="customer-settings-preview"><h3>Importvorschau</h3><p>{preview.shipments.length?1:0} neuer Auftrag · {preview.shipments.length} neue Sendungen · {preview.existing.length} bereits importiert</p>{!!preview.renamed.length&&<section className="alert info" style={{display:'block'}}><h4>Vorhandene Tourennamen übertragen</h4><p>Diese Aufträge behalten ihre Daten; der Name ALLERGO wird vom bisherigen zum neuen Auftrag übertragen:</p>{preview.renamed.map(j=><p key={j.jb_id}>{uiText("Auftrag ")}{j.jb_id}</p>)}<label><input type="checkbox" checked={replaceNames} disabled={busy||uncertain} onChange={e=>setReplaceNames(e.target.checked)}/> Die genannten Tourennamen übertragen</label></section>}{preview.sender&&<section><h4>Absender · Station 1 · erledigt</h4><p><AddressView value={preview.sender}/></p><p>Unterschriftsname: {preview.sender.signer||'–'}</p></section>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Station / Zeile</th><th>Transaktion</th><th>{uiText("Empfänger")}</th><th>Sendungsdaten</th></tr></thead><tbody>{preview.shipments.map((s,i)=><tr key={s.transaction}><td style={{verticalAlign:'top'}}>{i+2} / {s.line}</td><td style={{verticalAlign:'top'}}>{s.transaction}</td><td style={{verticalAlign:'top'}}><AddressView value={s.recipient}/></td><td style={{whiteSpace:'pre-wrap'}}>{s.remark}</td></tr>)}</tbody></table></div>{!!preview.existing.length&&<details><summary>Bereits importiert ({preview.existing.length})</summary>{preview.existing.map(s=><p key={s.transaction}>{s.transaction} · {s.recipient.name}</p>)}</details>}</section>}
|
||||
{result&&<section className="admin-success" role="status"><h3>ALLERGO-Import abgeschlossen</h3><p>{result.jobs.length} Aufträge angelegt{result.repeated?' · bestehende Speicherbestätigung wiederverwendet':''}.</p>{result.jobs.map(j=><p key={j.id}>{uiText("Auftrag ")}{j.id} · {j.name} · {j.stations} Stationen</p>)}</section>}{uncertain&&<p className="alert info">Die Speicherantwort ist unklar. Erneutes Prüfen verwendet denselben Vorgang einschließlich der bestätigten Namensübertragung.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{preview?<button className="button primary" disabled={busy||!!preview.renamed.length&&!replaceNames} onClick={()=>void save()}>{uncertain?'Import erneut prüfen':'ALLERGO-Auftrag anlegen'}</button>:<button className="button primary" disabled={busy||!options||!csv.trim()} onClick={()=>void prepare()}>{busy?'Wird geprüft …':'ALLERGO-Import prüfen'}</button>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
+219
-213
@@ -1,13 +1,38 @@
|
||||
import {HistoricalOrdersButton} from "./HistoricalOrders";
|
||||
import {InformationDashboard} from "./InformationDashboard";
|
||||
import {HeaderActivity} from "./HeaderActivity";
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {UiLanguageProvider,UiLanguageButton} from './UiLanguage';
|
||||
import {TransferPortal} from './TransferPortal';
|
||||
import {DataTransfer} from "./DataTransfer";
|
||||
import {WarehousePortal} from './WarehousePortal';
|
||||
import {CourierPortal} from './CourierPortal';
|
||||
import {CustomerPortal} from './CustomerPortal';
|
||||
import {CustomerOrderLabels} from './CustomerOrderLabels';
|
||||
import {OrderDetailMail} from "./OrderDetailMail";
|
||||
import {OrderDeliveryEvidence} from "./OrderDeliveryEvidence";
|
||||
import {WorkspaceHeadquarters,OrderWorkspaceSwitch,type WorkspaceOrder,type WorkspaceEntity} from './WorkspaceHeadquarters';
|
||||
import {OrderRankingBatch,RankingButtons,type RankingLaunch} from './OrderRankingBatch';
|
||||
import {OrderListFilterFields,type OrderFilters} from './OrderListFilters';
|
||||
import {OrderListTable,OrderListActionDialog,type OrderListResult,type OrderListAction} from './OrderListTable';
|
||||
import {MasterListColumnsDialog} from './MasterListColumns';
|
||||
import {MasterDirectoryView} from "./MasterDirectory";
|
||||
import {ParallelOrderLists} from './ParallelOrderLists';
|
||||
import type {ParallelSettings} from './ParallelListsSettings';
|
||||
import { MasterDataEditor } from "./BusinessForms";
|
||||
import { OrderWorkbench } from "./OrderWorkbench";
|
||||
import { CashflowLauncher } from "./Cashflow";
|
||||
import { Billing } from "./Billing";
|
||||
import { Forms } from "./Forms";
|
||||
import { Messages } from "./Messages";
|
||||
import { Appointments } from "./SalesWorkspace";
|
||||
import { Statistics } from "./Statistics";
|
||||
import type {ListPreferences} from "./EmployeeListPreferences";
|
||||
import { MyAccount } from "./MyAccount";
|
||||
import { Administration } from "./Administration";
|
||||
import { Field, useDiscardConfirmation } from "./FormElements";
|
||||
import { OrderEntryForm } from "./OrderEntryForm";
|
||||
import { OrderBatches } from "./OrderBatches";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import {
|
||||
@@ -30,7 +55,6 @@ import {
|
||||
LogOut,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -81,6 +105,7 @@ const modules = [
|
||||
{ id: "customers", label: "Kunden", icon: Users },
|
||||
{ id: "couriers", label: "Kuriere", icon: Truck },
|
||||
{ id: "invoices", label: "Abrechnung", icon: FileText },
|
||||
{ id: "transfer", label: "Datentransfer", icon: FileText },
|
||||
{ id: "forms", label: "Formulare", icon: FileText },
|
||||
{ id: "messages", label: "Nachrichten", icon: MessageSquare },
|
||||
{ id: "appointments", label: "Vertrieb", icon: CalendarDays },
|
||||
@@ -127,11 +152,12 @@ const customerLabel = (id: number | null | undefined, name?: string | null) =>
|
||||
const orderCustomerLabel = (order: Order, customer?: Customer) =>
|
||||
customerLabel(order.customerId, order.customerName?.trim() || customer?.name);
|
||||
function Badge({ status, label }: { status: OrderStatus; label?: string }) {
|
||||
const uiText=useUiText("admin/jb_list.php");
|
||||
const s = STATUS[status] || { label: status, className: "cancelled" };
|
||||
return (
|
||||
<span className={`badge ${s.className}`}>
|
||||
<i />
|
||||
{label || s.label}
|
||||
{label || uiText(s.label)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -162,6 +188,8 @@ function Login({
|
||||
onLogin: (u: User) => void;
|
||||
notice: string;
|
||||
}) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
const [system, setSystem] = useState<SystemStatus | null>(null),
|
||||
[connectionError, setConnectionError] = useState("");
|
||||
const checkConnection = useCallback(() => {
|
||||
@@ -177,6 +205,7 @@ function Login({
|
||||
useEffect(() => checkConnection(), [checkConnection]);
|
||||
const [username, setUsername] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[otp, setOtp] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function submit(e: FormEvent) {
|
||||
@@ -184,7 +213,8 @@ function Login({
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
onLogin(await login(username, password));
|
||||
onLogin(await login(username, password, otp));
|
||||
setOtp("");
|
||||
setPassword("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -231,7 +261,7 @@ function Login({
|
||||
<span className="eyebrow">WILLKOMMEN ZURÜCK</span>
|
||||
<h2>Bei Votian NG anmelden</h2>
|
||||
<p className="muted">
|
||||
Verwenden Sie Ihr vorhandenes VoTiAn-Konto der Zentrale.
|
||||
Verwenden Sie Ihr vorhandenes VoTiAn-Konto der Zentrale, des Lagers, als Kunde oder Transporteur.
|
||||
</p>
|
||||
<Field label="VoTiAn Benutzerkonto">
|
||||
<input
|
||||
@@ -241,7 +271,7 @@ function Login({
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Passwort">
|
||||
<Field label={uiText("Passwort")}>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -250,6 +280,9 @@ function Login({
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zwei-Faktor-Code (falls aktiviert)">
|
||||
<input value={otp} onChange={e => setOtp(e.target.value.replace(/[^0-9]/g, '').slice(0,6))} inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} placeholder="6-stelliger Code aus Ihrer Authenticator-App" />
|
||||
</Field>
|
||||
{(error || notice) && (
|
||||
<div role="alert" className="alert error">
|
||||
{error || notice}
|
||||
@@ -264,8 +297,7 @@ function Login({
|
||||
) : (
|
||||
<ArrowRight size={17} />
|
||||
)}
|
||||
Anmelden
|
||||
</button>
|
||||
{uiText("Anmelden")}</button>
|
||||
<div
|
||||
className={`connection-note ${system?.connected ? "connected" : "disconnected"}`}
|
||||
>
|
||||
@@ -315,6 +347,9 @@ function Login({
|
||||
);
|
||||
}
|
||||
export default function App() {
|
||||
const [workspaceModule,setWorkspaceModule]=useState<Module>("orders");
|
||||
const [workspaceOrder,setWorkspaceOrder]=useState<WorkspaceOrder|null>(null);
|
||||
const [workspaceEntity,setWorkspaceEntity]=useState<WorkspaceEntity|null>(null);
|
||||
const [user, setUser] = useState<User | null>(null),
|
||||
[checking, setChecking] = useState(true),
|
||||
[notice, setNotice] = useState("");
|
||||
@@ -329,6 +364,7 @@ export default function App() {
|
||||
const unsubscribe = onSessionExpired(() => {
|
||||
if (!active || !currentUser.current) return;
|
||||
currentUser.current = null;
|
||||
setWorkspaceModule("orders");setWorkspaceOrder(null);setWorkspaceEntity(null);
|
||||
setUser(null);
|
||||
setNotice("Die Sitzung ist abgelaufen. Bitte erneut anmelden.");
|
||||
});
|
||||
@@ -350,6 +386,7 @@ export default function App() {
|
||||
}, [acceptUser]);
|
||||
async function signOut() {
|
||||
await logout();
|
||||
setWorkspaceModule("orders");setWorkspaceOrder(null);setWorkspaceEntity(null);
|
||||
currentUser.current = null;
|
||||
setUser(null);
|
||||
setNotice("");
|
||||
@@ -362,30 +399,56 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
if (!user) return <Login onLogin={acceptUser} notice={notice} />;
|
||||
if((user.accountType??1)>=5)return <TransferPortal key={user.userId||user.username} user={user} onLogout={signOut}/>;
|
||||
if(user.accountType===4)return <WarehousePortal key={user.userId||user.username} user={user} onLogout={signOut}/>;
|
||||
if(user.accountType===2)return <UiLanguageProvider key={`${user.userId||user.username}:${user.headquartersId}`}><CustomerPortal user={user} onLogout={signOut}/></UiLanguageProvider>;
|
||||
if(user.accountType===3)return <CourierPortal key={user.userId||user.username} user={user} onLogout={signOut}/>;
|
||||
// Unmounting discards all user data and callbacks from the previous session.
|
||||
return <Workspace user={user} onLogout={signOut} />;
|
||||
return <UiLanguageProvider key={`${user.userId||user.username}:${user.headquartersId}`}><Workspace initialModule={workspaceModule} initialOrder={workspaceOrder} initialEntity={workspaceEntity} user={user} onChanged={(next,module,order,entity)=>{setWorkspaceModule(module);setWorkspaceOrder(order||null);setWorkspaceEntity(entity||null);acceptUser(next);}} onLogout={signOut} /></UiLanguageProvider>;
|
||||
}
|
||||
|
||||
function Workspace({
|
||||
user,
|
||||
initialModule,
|
||||
initialOrder,
|
||||
initialEntity,
|
||||
onChanged,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
initialModule: Module;
|
||||
initialOrder: WorkspaceOrder|null;
|
||||
initialEntity: WorkspaceEntity|null;
|
||||
onChanged: (user:User,module:Module,order?:WorkspaceOrder,entity?:WorkspaceEntity)=>void;
|
||||
onLogout: () => Promise<void>;
|
||||
}) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
const [orderLayout,setOrderLayout]=useState<OrderListResult|null>(null),[orderColumnsOpen,setOrderColumnsOpen]=useState(false),[orderColumnsRevision,setOrderColumnsRevision]=useState(0),[listAction,setListAction]=useState<OrderListAction|null>(null),[orderSort,setOrderSort]=useState(''),[orderDescending,setOrderDescending]=useState(false);
|
||||
const [foreignOrder,setForeignOrder]=useState<WorkspaceOrder|null>(null);
|
||||
const launchDone=useRef(false);
|
||||
const [openedEntity,setOpenedEntity]=useState(initialEntity);
|
||||
const [showAccount,setShowAccount]=useState(false);
|
||||
const [parallelSettings,setParallelSettings]=useState<ParallelSettings|null>(null),[parallel,setParallel]=useState(true),[parallelRefresh,setParallelRefresh]=useState(0);
|
||||
const parallelActive=useRef(parallel);parallelActive.current=parallel;
|
||||
const [loggingOut, setLoggingOut] = useState(false),
|
||||
[logoutError, setLogoutError] = useState("");
|
||||
const [system, setSystem] = useState<SystemStatus | null>(null),
|
||||
[orderTotal, setOrderTotal] = useState<number | null>(null);
|
||||
const [listPreferences,setListPreferences]=useState<ListPreferences|null>(null),[orderLimit,setOrderLimit]=useState(100);
|
||||
const refreshKey=`votian-list-refresh:${user.headquartersId}:${user.username}`;
|
||||
const initialDate = useRef(day()).current;
|
||||
const initialDatesApplied=useRef(false),dateRangeTouched=useRef(false);
|
||||
const queryRef = useRef(
|
||||
new URLSearchParams({ from: initialDate, to: initialDate }).toString(),
|
||||
);
|
||||
const [masterCompact,setMasterCompact]=useState(false);
|
||||
const [showCostCenters,setShowCostCenters]=useState(false),[costCenterName,setCostCenterName]=useState("");
|
||||
const [showOrderBatches,setShowOrderBatches]=useState(false);
|
||||
const [listedCenter,setListedCenter]=useState<{id:number;parent:number}|null>(null);
|
||||
const masterQueryRef = useRef({ module: "orders", search: "", showCostCenters:false,costCenterName:"" });
|
||||
const requestId = useRef(0);
|
||||
const [module, setModule] = useState<Module>("orders"),
|
||||
const [module, setModule] = useState<Module>(initialModule),
|
||||
[orders, setOrders] = useState<Order[]>([]),
|
||||
[customers, setCustomers] = useState<Customer[]>([]),
|
||||
[couriers, setCouriers] = useState<Courier[]>([]);
|
||||
@@ -398,6 +461,7 @@ function Workspace({
|
||||
[status, setStatus] = useState(""),
|
||||
[customerId, setCustomerId] = useState(""),
|
||||
[courierId, setCourierId] = useState(""),
|
||||
[numberFrom,setNumberFrom]=useState(""),[numberTo,setNumberTo]=useState(""),[vehicleType,setVehicleType]=useState("0"),
|
||||
[from, setFrom] = useState(initialDate),
|
||||
[to, setTo] = useState(initialDate),
|
||||
[autoRefresh, setAutoRefresh] = useState(false),
|
||||
@@ -413,13 +477,22 @@ function Workspace({
|
||||
[tourDay, setTourDay] = useState(day());
|
||||
const [adminRefresh,setAdminRefresh] = useState(0);
|
||||
const [adminDirty,setAdminDirty] = useState(false);
|
||||
const [accountDirty,setAccountDirty]=useState(false);
|
||||
const [confirmDiscard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{
|
||||
if(!adminDirty)return;
|
||||
if(!adminDirty&&!accountDirty)return;
|
||||
const leave=(event:BeforeUnloadEvent)=>{event.preventDefault();event.returnValue="";};
|
||||
window.addEventListener("beforeunload",leave);
|
||||
return()=>window.removeEventListener("beforeunload",leave);
|
||||
},[adminDirty]);
|
||||
},[adminDirty,accountDirty]);
|
||||
useEffect(()=>{
|
||||
if(!loaded||!initialOrder||launchDone.current)return;
|
||||
const c=new AbortController();
|
||||
request<Order>(`/orders/${initialOrder.order.id}`,{signal:c.signal}).then(order=>{if(c.signal.aborted)return;launchDone.current=true;if(initialOrder.action)setListAction({key:initialOrder.action,order});else setSelected(order);}).catch(e=>{if(!c.signal.aborted){launchDone.current=true;setError(e.message);}});
|
||||
return()=>c.abort();
|
||||
},[loaded,initialOrder]);
|
||||
function openListOrder(order:Order){if(order.headquartersId&&order.headquartersId!==user.headquartersId)setForeignOrder({order});else setSelected(order);}
|
||||
function openListAction(action:OrderListAction){if(action.order.headquartersId&&action.order.headquartersId!==user.headquartersId)setForeignOrder({order:action.order,action:action.key});else setListAction(action);}
|
||||
const caps = system?.capabilities;
|
||||
const moduleRights: Record<Module, boolean> = {
|
||||
orders: !!caps?.readOrders,
|
||||
@@ -427,7 +500,8 @@ function Workspace({
|
||||
tours: !!caps?.readOrders,
|
||||
customers: !!caps?.readCustomers,
|
||||
couriers: !!caps?.readCouriers,
|
||||
invoices: !!caps?.readInvoices,
|
||||
invoices: !!caps?.readInvoices||!!system?.cashflow,
|
||||
transfer: !!caps?.readTransfers,
|
||||
reports: !!caps?.readStatistics,
|
||||
appointments: !!caps?.readAppointments,
|
||||
administration: true,
|
||||
@@ -442,6 +516,7 @@ function Workspace({
|
||||
customers: "admin/customer_list.php",
|
||||
couriers: "admin/courier_list.php",
|
||||
invoices: "invoice/invoice.php",
|
||||
transfer: "import/data_transfer.php",
|
||||
reports: "statistic/statistic.php",
|
||||
appointments: "groupware/appointment.php",
|
||||
administration: "admin/menu.php",
|
||||
@@ -487,21 +562,27 @@ function Workspace({
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
const preferences=await request<ListPreferences>("/account/list-preferences");
|
||||
if(seq!==requestId.current)return;setListPreferences(preferences);
|
||||
if(!initialDatesApplied.current&&masterQueryRef.current.module==='orders'){
|
||||
initialDatesApplied.current=true;
|
||||
if(!dateRangeTouched.current&&preferences.initialDates){
|
||||
const dates=preferences.initialDates,query=new URLSearchParams(queryRef.current);
|
||||
query.set('from',dates.from);query.set('to',dates.to);queryRef.current=query.toString();
|
||||
setFrom(dates.from);setTo(dates.to);
|
||||
}
|
||||
}
|
||||
const listSetup=await request<ParallelSettings>("/account/parallel-lists");
|
||||
if(seq!==requestId.current)return;setParallelSettings(listSetup);setParallelRefresh(v=>v+1);
|
||||
const currentStatus=new URLSearchParams(queryRef.current).get("status");
|
||||
const activeRefresh=["DISPOSITION","OFFERED","IN_PROGRESS"].includes(currentStatus||"");
|
||||
if(!activeRefresh)setAutoRefresh(false);
|
||||
else if(!preferences.values.keepRefresh)setAutoRefresh(true);
|
||||
else {try{const remembered=sessionStorage.getItem(refreshKey);if(remembered!==null)setAutoRefresh(remembered==="1");}catch{/* keep the current selection when storage is unavailable */}}
|
||||
const caps = state.capabilities;
|
||||
const [o, c, k] = await Promise.all([
|
||||
caps.readOrders
|
||||
? request<Order[]>(
|
||||
"/orders" + (queryRef.current ? "?" + queryRef.current : ""),
|
||||
{},
|
||||
(response) => {
|
||||
if (seq === requestId.current)
|
||||
setOrderTotal(
|
||||
response.headers.has("X-Total-Count")
|
||||
? Number(response.headers.get("X-Total-Count"))
|
||||
: null,
|
||||
);
|
||||
},
|
||||
)
|
||||
? (masterQueryRef.current.module!=="orders"?request<Order[]>("/orders"+(queryRef.current?"?"+queryRef.current:"")):request<OrderListResult>("/orders/list" + (queryRef.current ? "?" + queryRef.current : "")).then(d=>{if(seq===requestId.current){setOrderLayout(d);setOrderLimit(d.limit);setOrderTotal(d.total);}return d.orders;}))
|
||||
: Promise.resolve([]),
|
||||
caps.readCustomers
|
||||
? request<Customer[]>(
|
||||
@@ -532,7 +613,7 @@ function Workspace({
|
||||
);
|
||||
setLoaded(true);
|
||||
setError("");
|
||||
setSelected((s) => (s ? o.find((x) => x.id === s.id) || null : null));
|
||||
if(!parallelActive.current&&!initialOrder)setSelected((s) => (s ? o.find((x) => x.id === s.id) || null : null));
|
||||
|
||||
} catch (e) {
|
||||
if (seq !== requestId.current) return;
|
||||
@@ -542,6 +623,8 @@ function Workspace({
|
||||
if (seq === requestId.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const [rankingBatch,setRankingBatch]=useState<RankingLaunch|null>(null);
|
||||
const [specialFilters,setSpecialFilters]=useState<OrderFilters>({});
|
||||
const orderQuery = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
if (search && ["orders", "dispatch"].includes(module)) p.set("q", search);
|
||||
@@ -550,12 +633,18 @@ function Workspace({
|
||||
if (courierId) p.set("courierId", courierId);
|
||||
if (from) p.set("from", from);
|
||||
if (to) p.set("to", to);
|
||||
if(["orders","dispatch"].includes(module)){
|
||||
if(Object.keys(specialFilters).length)p.set("filters",JSON.stringify(specialFilters));
|
||||
p.set("personal","true");p.set("offset",String(module==="orders"?(page-1)*orderLimit:0));
|
||||
if(numberFrom)p.set("numberFrom",numberFrom);if(numberTo)p.set("numberTo",numberTo);if(vehicleType!=="0")p.set("vehicleType",vehicleType);
|
||||
}
|
||||
if (module === "tours") {
|
||||
p.set("from", tourDay);
|
||||
p.set("to", tourDay);
|
||||
}
|
||||
if(orderSort&&module==="orders"){p.set("sort",orderSort);p.set("descending",String(orderDescending));}p.set("columns","true");
|
||||
return p.toString();
|
||||
}, [search, status, customerId, courierId, from, to, module, tourDay]);
|
||||
}, [search, status, customerId, courierId, from, to, module, tourDay,numberFrom,numberTo,vehicleType,page,orderLimit,orderSort,orderDescending,specialFilters]);
|
||||
queryRef.current = orderQuery;
|
||||
masterQueryRef.current = { module, search, showCostCenters, costCenterName };
|
||||
const dataQuery = ["customers", "couriers"].includes(module)
|
||||
@@ -572,24 +661,24 @@ function Workspace({
|
||||
if (user) void load();
|
||||
}, [user, load]);
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !user) return;
|
||||
if (!autoRefresh || !user || parallel&&module==="orders" || orderColumnsOpen || listAction || rankingBatch || foreignOrder) return;
|
||||
const id = setInterval(() => void load(), 30000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, user, load]);
|
||||
}, [autoRefresh, user, load,parallel,module,orderColumnsOpen,listAction,rankingBatch,foreignOrder]);
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const id = setTimeout(() => setToast(""), 4500);
|
||||
return () => clearTimeout(id);
|
||||
}, [toast]);
|
||||
useEffect(
|
||||
() => setPage(1),
|
||||
[search, status, customerId, courierId, from, to, module],
|
||||
() => {setPage(1);setOrderSort('');},
|
||||
[search, status, customerId, courierId, from, to, module,numberFrom,numberTo,vehicleType,specialFilters],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
orders
|
||||
.slice()
|
||||
.sort((a, b) => (b.pickupAt || "").localeCompare(a.pickupAt || "")),
|
||||
,
|
||||
[orders],
|
||||
);
|
||||
const todayOrders = orders.filter((o) => datePart(o.pickupAt) === day());
|
||||
@@ -615,13 +704,14 @@ function Workspace({
|
||||
},
|
||||
);
|
||||
function resetDateRange() {
|
||||
dateRangeTouched.current=true;
|
||||
const today = day();
|
||||
setFrom(today);
|
||||
setTo(today);
|
||||
}
|
||||
async function switchModule(m: Module) {
|
||||
if(m!==module&&adminDirty&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen?")))return;
|
||||
setModule(m);
|
||||
if(m!==module&&(adminDirty||accountDirty)&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen?")))return;
|
||||
setSpecialFilters({});setModule(m);setNumberFrom("");setNumberTo("");setVehicleType("0");
|
||||
setSearch("");
|
||||
setSelected(null);
|
||||
setStatus("");
|
||||
@@ -638,6 +728,8 @@ function Workspace({
|
||||
if (courierId) p.set("courierId", courierId);
|
||||
if (from) p.set("from", from);
|
||||
if (to) p.set("to", to);
|
||||
if(["orders","dispatch"].includes(module)){if(Object.keys(specialFilters).length)p.set("filters",JSON.stringify(specialFilters));p.set("personal","true");if(numberFrom)p.set("numberFrom",numberFrom);if(numberTo)p.set("numberTo",numberTo);if(vehicleType!=="0")p.set("vehicleType",vehicleType);}
|
||||
if(module==="orders"){p.set("columns","true");if(orderSort){p.set("sort",orderSort);p.set("descending",String(orderDescending));}}
|
||||
await exportCsv(p);
|
||||
setToast("CSV-Export wurde erstellt.");
|
||||
} catch (e) {
|
||||
@@ -653,6 +745,7 @@ function Workspace({
|
||||
customers: "Auftraggeber, Ansprechpartner und Abrechnungsdaten.",
|
||||
couriers: "Ihre Kuriere, Fahrzeuge und aktuelle Verfügbarkeit.",
|
||||
invoices: "Erledigte Leistungen bündeln und Rechnungen verwalten.",
|
||||
transfer: "Gemeinsame und zugeordnete Dokumente verwalten.",
|
||||
administration: "Stammdaten und Vorgaben Ihrer Niederlassung verwalten.",
|
||||
forms: "Formulare, Feldzuordnungen und Vorlagen verwalten.",
|
||||
messages: "Mitteilungen, Endgeräte und Nachrichtengruppen verwalten.",
|
||||
@@ -661,7 +754,12 @@ function Workspace({
|
||||
"Zeitraumstatistiken, Vergleiche und Auswertungen über Ihre Niederlassungen.",
|
||||
};
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={"app-shell "+(listPreferences?.values.gray?"theme-gray":"theme-blue")}>
|
||||
{foreignOrder&&<OrderWorkspaceSwitch target={foreignOrder} onClose={()=>setForeignOrder(null)} onChanged={(next,target)=>onChanged(next,module,target)}/>}
|
||||
{rankingBatch&&<OrderRankingBatch initial={rankingBatch} onDirtyChange={setAccountDirty} onClose={()=>setRankingBatch(null)} onSaved={()=>void load()}/>}
|
||||
{orderColumnsOpen&&<MasterListColumnsDialog kind="orders" onDirtyChange={setAccountDirty} onClose={()=>{setOrderColumnsOpen(false);setOrderSort('');setOrderColumnsRevision(v=>v+1);void load();}} onSaved={()=>setPage(1)}/>}
|
||||
{listAction&&<OrderListActionDialog action={listAction} onDirtyChange={setAccountDirty} onClose={()=>{setListAction(null);void load();}} onSaved={()=>void load()}/>}
|
||||
{showAccount&&<MyAccount onPreferencesSaved={()=>{setPage(1);setOrderSort('');setOrderColumnsRevision(v=>v+1);void load();}} onClose={()=>setShowAccount(false)} onDirtyChange={setAccountDirty}/>}
|
||||
<header className="topbar">
|
||||
<div className="product-lockup">
|
||||
<span className="wordmark">
|
||||
@@ -671,6 +769,8 @@ function Workspace({
|
||||
<span className="product-caption">KURIER & LOGISTIK</span>
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
<UiLanguageButton disabled={adminDirty||accountDirty||loggingOut||!!orderEditor||!!masterEditor||!!selected||showAccount||showOrderBatches||orderColumnsOpen||!!listAction||!!rankingBatch||!!foreignOrder||!!openedEntity} onActive={setAccountDirty}/>
|
||||
<WorkspaceHeadquarters user={user} blocked={adminDirty||accountDirty||loggingOut||!!orderEditor||!!masterEditor||!!selected||showAccount||showOrderBatches||orderColumnsOpen||!!listAction||!!rankingBatch||!!foreignOrder||!!openedEntity} onChanged={next=>onChanged(next,module)}/>
|
||||
<span
|
||||
className={`connection-badge ${system?.connected ? "connected" : "disconnected"}`}
|
||||
>
|
||||
@@ -683,12 +783,12 @@ function Workspace({
|
||||
className="header-logo"
|
||||
alt="Stadtbote"
|
||||
/>
|
||||
<div className="user-avatar">{initials(user.username)}</div>
|
||||
<button className="user-avatar" title="Mein Konto" aria-label="Mein Konto" onClick={()=>setShowAccount(true)}>{initials(user.username)}</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
disabled={loggingOut}
|
||||
onClick={async () => {
|
||||
if(adminDirty&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen und abmelden?")))return;
|
||||
if((adminDirty||accountDirty)&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen und abmelden?")))return;
|
||||
setLoggingOut(true);
|
||||
setLogoutError("");
|
||||
try {
|
||||
@@ -699,13 +799,17 @@ function Workspace({
|
||||
setLoggingOut(false);
|
||||
}
|
||||
}}
|
||||
aria-label="Abmelden"
|
||||
title="Abmelden"
|
||||
aria-label={uiText("Abmelden")}
|
||||
title={uiText("Abmelden")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="workspace-activity">
|
||||
<InformationDashboard disabled={adminDirty||accountDirty||loggingOut||!!orderEditor||!!masterEditor||!!selected||showAccount||showOrderBatches||orderColumnsOpen||!!listAction||!!rankingBatch||!!foreignOrder||!!openedEntity} onActive={setAccountDirty}/>
|
||||
<HeaderActivity disabled={adminDirty||accountDirty||loggingOut||!!orderEditor||!!masterEditor||!!selected||showAccount||showOrderBatches||orderColumnsOpen||!!listAction||!!rankingBatch||!!foreignOrder||!!openedEntity} onActive={setAccountDirty}/>
|
||||
</div>
|
||||
<nav className="main-nav" aria-label="Hauptnavigation">
|
||||
<div className="nav-links">
|
||||
{modules.map((m) => (
|
||||
@@ -720,19 +824,19 @@ function Workspace({
|
||||
onClick={() => switchModule(m.id)}
|
||||
>
|
||||
<m.icon size={17} />
|
||||
{m.label}
|
||||
{m.id === "orders" && loaded && caps?.readOrders && <span>{activeOrders.length}</span>}
|
||||
{uiText(m.label)}
|
||||
{m.id === "orders" && !parallel && loaded && caps?.readOrders && <span>{activeOrders.length}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-context">
|
||||
<span className="online-dot" />{" "}
|
||||
{module === "administration" ? "Verwaltung" : module === "appointments" ? "Vertrieb" : module === "messages" ? "Nachrichten" : module === "forms" ? "Formulare" : canEdit ? "Disposition" : caps?.createOrders ? "Auftragserfassung" : "Lesezugriff"}
|
||||
{module === "administration" ? uiText("Verwaltung") : module === "appointments" ? uiText("Vertrieb") : module === "messages" ? uiText("Nachrichten") : module === "forms" ? "Formulare" : canEdit ? uiText("Disposition") : caps?.createOrders ? uiText("Auftragserfassung") : "Lesezugriff"}
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div className="breadcrumb">
|
||||
Arbeitsplatz <ChevronRight size={12} /> {currentModule.label}
|
||||
Arbeitsplatz <ChevronRight size={12} /> {uiText(currentModule.label)}
|
||||
<span>
|
||||
{new Date().toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
@@ -751,8 +855,8 @@ function Workspace({
|
||||
: currentModule.label.toUpperCase()}
|
||||
</div>
|
||||
<h1>
|
||||
{currentModule.label}
|
||||
{module === "orders" && loaded && caps?.readOrders && (
|
||||
{uiText(currentModule.label)}
|
||||
{module === "orders" && !parallel && loaded && caps?.readOrders && (
|
||||
<span className="count-pill">{orders.length}</span>
|
||||
)}
|
||||
</h1>
|
||||
@@ -761,13 +865,14 @@ function Workspace({
|
||||
<div className="heading-actions">
|
||||
<button
|
||||
className="button"
|
||||
onClick={async () => {if(adminDirty&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen und neu laden?")))return;["administration","reports","appointments","messages","forms"].includes(module) ? setAdminRefresh(v=>v+1) : void load();}}
|
||||
onClick={async () => {if((adminDirty||accountDirty)&&!(await confirmDiscard("Ungespeicherte Änderungen verwerfen und neu laden?")))return;["administration","reports","appointments","messages","forms","customers","couriers","transfer"].includes(module) ? setAdminRefresh(v=>v+1) : void load();}}
|
||||
disabled={loading}
|
||||
title="Daten aktualisieren"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? "spin" : ""} />
|
||||
<span>Aktualisieren</span>
|
||||
<span>{uiText("Aktualisieren")}</span>
|
||||
</button>
|
||||
{caps?.createOrders&&["orders","dispatch","tours"].includes(module)&&<button className="button" onClick={()=>setShowOrderBatches(true)}>{uiText("Listenerfassung")}</button>}
|
||||
{(canWriteModule || (caps?.createOrders && ["orders", "dispatch", "tours"].includes(module))) &&
|
||||
(["orders", "dispatch", "tours"].includes(module) ? (
|
||||
<button
|
||||
@@ -783,11 +888,12 @@ function Workspace({
|
||||
onClick={() => setMasterEditor({ kind: module, value: null })}
|
||||
>
|
||||
<Plus size={17} />
|
||||
{module === "customers" ? "Neuer Kunde" : "Neuer Kurier"}
|
||||
{module === "customers" ? uiText("Neuer Kunde") : "Neuer Kurier"}
|
||||
</button>
|
||||
) : null)}
|
||||
</div>
|
||||
</div>
|
||||
{user.workspaceNotice&&<p className="alert info" role="status">{user.workspaceNotice}</p>}
|
||||
{(logoutError || error) && (
|
||||
<div className="alert error" role="alert">
|
||||
<XCircle size={18} />
|
||||
@@ -804,7 +910,7 @@ function Workspace({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{module === "forms" ? <Forms refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : module === "messages" ? <Messages refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : module === "administration" ? <Administration refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : module === "appointments" ? <Appointments refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : !loaded ? (
|
||||
{module === "forms" ? <Forms refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : module === "messages" ? <Messages refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : module === "administration" ? <Administration refresh={adminRefresh} onDirtyChange={setAdminDirty} workspaceHeadquarters={user.headquartersId} onWorkspaceChanged={(next,target)=>onChanged(next,target.kind==='order'?'orders':'couriers',undefined,target)}/> : module === "appointments" ? <Appointments refresh={adminRefresh} onDirtyChange={setAdminDirty}/> : !loaded ? (
|
||||
<section className="panel offline-panel">
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -861,12 +967,12 @@ function Workspace({
|
||||
<>
|
||||
|
||||
|
||||
{["orders", "dispatch", "tours"].includes(module) && (
|
||||
{["orders", "dispatch", "tours"].includes(module)&&!(module==="orders"&¶llel) && (
|
||||
<div className="data-scope">
|
||||
<span>
|
||||
<strong>Geladener Ausschnitt:</strong> {orders.length}
|
||||
{orderTotal !== null ? ` von ${orderTotal}` : ""} Aufträge ·
|
||||
höchstens {system?.maxResults || 500} Einträge je Abfrage.
|
||||
höchstens {orderLimit} Einträge je Abfrage.
|
||||
</span>
|
||||
<span>
|
||||
Filter eingrenzen. Kennzahlen beziehen sich nur auf diesen
|
||||
@@ -874,7 +980,7 @@ function Workspace({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{["orders", "dispatch", "tours"].includes(module) && (
|
||||
{["orders", "dispatch", "tours"].includes(module)&&!(module==="orders"&¶llel) && (
|
||||
<div className="metrics">
|
||||
<Metric
|
||||
label="Aufträge heute"
|
||||
@@ -919,7 +1025,8 @@ function Workspace({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(module === "orders" || module === "dispatch") && (
|
||||
{module==="orders"&¶llelSettings&&listPreferences&&<><div className="parallel-list-switch"><HistoricalOrdersButton/><button className={'button '+(parallel?'primary':'')} onClick={()=>setParallel(true)}>Persönliche Listen ({parallelSettings.values.count})</button><button className={'button '+(!parallel?'primary':'')} onClick={()=>setParallel(false)}>Einzelliste</button><button className="button" onClick={()=>setOrderColumnsOpen(true)}>Listenspalten</button></div>{parallel&&<><p className="form-hint">Jede Liste hat eigene Filter und Seiten. Alle Listen sind gleich hoch und lassen sich unabhängig scrollen. Anzahl und Startstatus ändern Sie unter Mein Konto → Listenparameter.</p>{parallelSettings.warning&&<p className="alert info">{parallelSettings.warning}</p>}<ParallelOrderLists columnsRevision={orderColumnsRevision} settings={parallelSettings} preferences={listPreferences} user={user} customers={customers} couriers={couriers} canExport={!!caps?.exportOrders} canEdit={canEdit} canReadCouriers={!!caps?.readCouriers} refresh={parallelRefresh} paused={!!orderEditor||!!selected||showAccount||showOrderBatches||orderColumnsOpen||!!listAction||!!rankingBatch||!!foreignOrder||!!openedEntity} onRanking={setRankingBatch} onOpen={openListOrder} onEdit={setOrderEditor} onAction={openListAction}/></>}</>}
|
||||
{(module === "orders"&&!parallel || module === "dispatch") && (
|
||||
<section className="panel order-panel">
|
||||
<div className="panel-tabs">
|
||||
<div className="tabs">
|
||||
@@ -966,7 +1073,7 @@ function Workspace({
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
onChange={(e) => {dateRangeTouched.current=true;setFrom(e.target.value);}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Abholung bis">
|
||||
@@ -974,7 +1081,7 @@ function Workspace({
|
||||
type="date"
|
||||
min={from}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
onChange={(e) => {dateRangeTouched.current=true;setTo(e.target.value);}}
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
@@ -1014,6 +1121,7 @@ function Workspace({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canEdit&&(!specialFilters.headquarters||specialFilters.headquarters===String(user.headquartersId))&&<RankingButtons from={from} onOpen={setRankingBatch}/>}
|
||||
<select
|
||||
aria-label="Status filtern"
|
||||
value={status}
|
||||
@@ -1043,8 +1151,7 @@ function Workspace({
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter size={15} />
|
||||
Filter
|
||||
{customerId && <i className="filter-dot" />}
|
||||
{uiText("Filter")}{(customerId||Object.keys(specialFilters).length>0) && <i className="filter-dot" />}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
@@ -1058,12 +1165,15 @@ function Workspace({
|
||||
onClick={() => void download()}
|
||||
>
|
||||
<ArrowDownToLine size={15} />
|
||||
Export
|
||||
</button>
|
||||
{uiText("Export")}</button>
|
||||
</div>
|
||||
{showFilters && (
|
||||
<div className="expanded-filters">
|
||||
<Field label="Kunde">
|
||||
<Field label="Auftragsnummer von"><input inputMode="numeric" pattern="[0-9]*" maxLength={16} value={numberFrom} onChange={e=>{if(/^\d*$/.test(e.target.value))setNumberFrom(e.target.value);}} onBlur={()=>{if(listPreferences?.values.copyNumber)setNumberTo(numberFrom);}}/></Field>
|
||||
<Field label="Auftragsnummer bis"><input inputMode="numeric" pattern="[0-9]*" maxLength={16} value={numberTo} onChange={e=>{if(/^\d*$/.test(e.target.value))setNumberTo(e.target.value);}}/></Field>
|
||||
<Field label="Fahrzeugtyp"><select value={vehicleType} onChange={e=>setVehicleType(e.target.value)}><option value="0">Persönliche Fahrzeuggrenze{listPreferences?.values.vehicleMax?` (bis ${listPreferences.vehicles.find(v=>v.id===listPreferences.values.vehicleMax)?.label||listPreferences.values.vehicleMax})`:': alle'}</option>{listPreferences?.vehicles.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
|
||||
{(numberFrom||numberTo)&&<p className="form-hint">Die Nummernsuche ersetzt den Abholzeitraum. Status und übrige Filter bleiben wirksam.</p>}
|
||||
<Field label={uiText("Kunde")}>
|
||||
<select
|
||||
value={customerId}
|
||||
onChange={(e) => setCustomerId(e.target.value)}
|
||||
@@ -1082,138 +1192,18 @@ function Workspace({
|
||||
setSearch("");
|
||||
setStatus("");
|
||||
setCustomerId("");
|
||||
setCourierId("");
|
||||
setCourierId("");setNumberFrom("");setNumberTo("");setVehicleType("0");setSpecialFilters({});
|
||||
resetDateRange();
|
||||
}}
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
<OrderListFilterFields key={`${user.headquartersId}:${user.username}`} scope="Einzelliste" value={specialFilters} onChange={setSpecialFilters}/>
|
||||
</div>
|
||||
)}
|
||||
{module === "orders" ? (
|
||||
<>
|
||||
<div className="table-scroll">
|
||||
<table className="data-table order-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Auftrag</th>
|
||||
<th>Status</th>
|
||||
<th>Kunde / Referenz</th>
|
||||
<th>
|
||||
Abholung <span>→</span> Zustellung
|
||||
</th>
|
||||
<th>Abholtermin</th>
|
||||
<th>Kurier</th>
|
||||
<th>Fahrzeug</th>
|
||||
<th className="align-right">Netto</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered
|
||||
.slice((page - 1) * 12, page * 12)
|
||||
.map((o) => (
|
||||
<tr
|
||||
key={o.id}
|
||||
onClick={() => setSelected(o)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setSelected(o);
|
||||
}}
|
||||
aria-label={`Auftrag ${o.number} öffnen`}
|
||||
>
|
||||
<td>
|
||||
<strong className="order-number">
|
||||
{o.number}
|
||||
</strong>
|
||||
<span className="cell-sub">{o.service}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
status={o.status}
|
||||
label={o.legacyStatusLabel}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong className="truncate customer-cell">
|
||||
{orderCustomerName(o)}
|
||||
</strong>
|
||||
<span className="cell-sub">
|
||||
{o.reference || "Keine Referenz"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="route-cell">
|
||||
<span>
|
||||
<i className="stop-dot start" />
|
||||
{o.stops[0]?.city || "–"}
|
||||
<small>{o.stops[0]?.street}</small>
|
||||
</span>
|
||||
<span>
|
||||
<i className="stop-dot end" />
|
||||
{o.stops.at(-1)?.city || "–"}
|
||||
<small>{o.stops.at(-1)?.street}</small>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
{time(o.pickupAt)}{" "}
|
||||
<span className="normal muted">Uhr</span>
|
||||
</strong>
|
||||
<span className="cell-sub">
|
||||
{date(o.pickupAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{o.courierId ? (
|
||||
<span className="courier-cell">
|
||||
<span className="mini-avatar">
|
||||
{initials(courierName(o.courierId))}
|
||||
</span>
|
||||
<span>
|
||||
{courierName(o.courierId)}
|
||||
<small>
|
||||
{
|
||||
couriers.find(
|
||||
(c) => c.id === o.courierId,
|
||||
)?.number
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="unassigned">
|
||||
Nicht zugewiesen
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="vehicle">
|
||||
<Truck size={14} />
|
||||
{o.vehicle}
|
||||
</span>
|
||||
</td>
|
||||
<td className="align-right price-cell">
|
||||
{euro(o.netPrice)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(o);
|
||||
}}
|
||||
aria-label={`Details ${o.number}`}
|
||||
>
|
||||
<MoreHorizontal size={19} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OrderListTable orders={filtered} layout={orderLayout} headquarters={user.headquartersId} sort={orderSort} descending={orderDescending} onSort={(key,descending)=>{setOrderDescending(descending);setOrderSort(key);setPage(1);}} onOpen={openListOrder} onAction={openListAction}/>
|
||||
{!filtered.length && (
|
||||
<Empty
|
||||
title="Keine passenden Aufträge"
|
||||
@@ -1223,7 +1213,7 @@ function Workspace({
|
||||
<div className="table-footer">
|
||||
<span>
|
||||
{filtered.length
|
||||
? `${(page - 1) * 12 + 1}–${Math.min(page * 12, filtered.length)} von ${filtered.length}`
|
||||
? `${(page - 1) * orderLimit + 1}–${(page-1)*orderLimit+filtered.length} von ${orderTotal??filtered.length}`
|
||||
: "0"}{" "}
|
||||
Aufträgen
|
||||
</span>
|
||||
@@ -1231,14 +1221,14 @@ function Workspace({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
onChange={(e) => {setAutoRefresh(e.target.checked);try{sessionStorage.setItem(refreshKey,e.target.checked?"1":"0");}catch{/* preference remains valid for this view */}}}
|
||||
/>
|
||||
Automatisch aktualisieren
|
||||
</label>
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="icon-button"
|
||||
disabled={page === 1}
|
||||
disabled={loading||page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
aria-label="Vorherige Seite"
|
||||
>
|
||||
@@ -1246,11 +1236,11 @@ function Workspace({
|
||||
</button>
|
||||
<span>
|
||||
{page} /{" "}
|
||||
{Math.max(1, Math.ceil(filtered.length / 12))}
|
||||
{Math.max(1, Math.ceil((orderTotal??filtered.length) / orderLimit))}
|
||||
</span>
|
||||
<button
|
||||
className="icon-button"
|
||||
disabled={page >= Math.ceil(filtered.length / 12)}
|
||||
disabled={loading||page >= Math.ceil((orderTotal??filtered.length) / orderLimit)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
aria-label="Nächste Seite"
|
||||
>
|
||||
@@ -1284,7 +1274,7 @@ function Workspace({
|
||||
<button
|
||||
className="dispatch-card"
|
||||
key={o.id}
|
||||
onClick={() => setSelected(o)}
|
||||
onClick={() => openListOrder(o)}
|
||||
>
|
||||
<span className="card-line">
|
||||
<strong>{o.number}</strong>
|
||||
@@ -1373,7 +1363,7 @@ function Workspace({
|
||||
<button
|
||||
className="tour-order"
|
||||
key={o.id}
|
||||
onClick={() => setSelected(o)}
|
||||
onClick={() => openListOrder(o)}
|
||||
>
|
||||
<span className="tour-time">
|
||||
{time(o.pickupAt)}
|
||||
@@ -1414,7 +1404,8 @@ function Workspace({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{(module === "customers" || module === "couriers") && (
|
||||
{(module === "customers" || module === "couriers") && <><div className="parallel-list-switch"><button className={'button '+(!masterCompact?'primary':'')} onClick={async()=>{if(!adminDirty||await confirmDiscard('Ungespeicherte Änderungen verwerfen?'))setMasterCompact(false);}}>Persönliche Spalten</button><button className={'button '+(masterCompact?'primary':'')} onClick={async()=>{if(!adminDirty||await confirmDiscard('Ungespeicherte Änderungen verwerfen?'))setMasterCompact(true);}}>Kompaktansicht</button></div>{!masterCompact&&<MasterDirectoryView key={module} kind={module} refresh={String(adminRefresh)+lastSync} onDirtyChange={setAdminDirty}/>}</>}
|
||||
{(module === "customers" || module === "couriers") && masterCompact && (
|
||||
<section className="panel">
|
||||
<div className="section-toolbar">
|
||||
<div>
|
||||
@@ -1453,21 +1444,21 @@ function Workspace({
|
||||
<thead>
|
||||
{module === "customers" ? (
|
||||
<tr>
|
||||
{showCostCenters&&<th>Kostenstelle</th>}
|
||||
<th>Kunde</th>
|
||||
{showCostCenters&&<th>{uiText("Kostenstelle")}</th>}
|
||||
<th>{uiText("Kunde")}</th>
|
||||
<th>Adresse</th>
|
||||
<th>Ansprechpartner</th>
|
||||
<th>Kontakt</th>
|
||||
<th>Zahlungsziel</th>
|
||||
<th>Status</th>
|
||||
<th>{uiText("Status")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<th>Kurier</th>
|
||||
<th>Fahrzeug</th>
|
||||
<th>{uiText("Kurier")}</th>
|
||||
<th>{uiText("Fahrzeug")}</th>
|
||||
<th>Kennzeichen</th>
|
||||
<th>Telefon</th>
|
||||
<th>{uiText("Telefon")}</th>
|
||||
<th>Verfügbarkeit</th>
|
||||
<th>Aktiv im geladenen Ausschnitt</th>
|
||||
<th />
|
||||
@@ -1555,7 +1546,7 @@ function Workspace({
|
||||
>
|
||||
<i />
|
||||
{c.active
|
||||
? COURIER_STATUS[c.status]
|
||||
? uiText(COURIER_STATUS[c.status])
|
||||
: "Inaktiv"}
|
||||
</span>
|
||||
</td>
|
||||
@@ -1597,7 +1588,8 @@ function Workspace({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{module === "invoices" && <Billing refresh={adminRefresh} onDirtyChange={setAdminDirty}/>}
|
||||
{module === "transfer" && <DataTransfer key={adminRefresh} onDirtyChange={setAdminDirty}/>}
|
||||
{module === "invoices" && <>{system?.cashflow&&<CashflowLauncher onDirtyChange={setAdminDirty}/>} {caps?.readInvoices&&<Billing refresh={adminRefresh} onDirtyChange={setAdminDirty}/>}</>}
|
||||
{module === "reports" && (
|
||||
<Statistics key={adminRefresh}/>
|
||||
)}
|
||||
@@ -1623,6 +1615,7 @@ function Workspace({
|
||||
</div>
|
||||
)}
|
||||
{selected && <OrderDetails order={selected} customer={customers.find(c=>c.id===selected.customerId)} couriers={couriers} canEdit={canEdit} onClose={()=>setSelected(null)} onEdit={()=>setOrderEditor(selected)}/>}
|
||||
{showOrderBatches&&<OrderBatches onClose={()=>setShowOrderBatches(false)} onSaved={()=>void load()} onOpenOrder={canEdit?(id)=>{setShowOrderBatches(false);setOrderEditor({id} as Order);}:undefined}/>}
|
||||
{orderEditor === "new" && (
|
||||
<OrderEntryForm
|
||||
onClose={() => setOrderEditor(null)}
|
||||
@@ -1638,6 +1631,8 @@ function Workspace({
|
||||
/>
|
||||
)}
|
||||
{orderEditor && orderEditor !== "new" && <OrderWorkbench id={orderEditor.id} onClose={()=>setOrderEditor(null)} onSaved={()=>void load()}/>}
|
||||
{openedEntity?.kind==='order'&&<OrderWorkbench id={openedEntity.id} onClose={()=>setOpenedEntity(null)} onSaved={()=>void load()}/>}
|
||||
{openedEntity?.kind==='vehicle'&&<MasterDataEditor kind="vehicles" id={openedEntity.id} parent={openedEntity.parent} onClose={()=>setOpenedEntity(null)} onSaved={()=>void load()}/>}
|
||||
{listedCenter && <MasterDataEditor kind="centers" id={listedCenter.id} parent={listedCenter.parent} onClose={()=>setListedCenter(null)} onSaved={()=>void load()}/>}
|
||||
{masterEditor && <MasterDataEditor kind={masterEditor.kind} id={masterEditor.value?.id||0} onClose={()=>setMasterEditor(null)} onSaved={()=>void load()}/>}
|
||||
|
||||
@@ -1669,13 +1664,18 @@ function Metric({
|
||||
);
|
||||
}
|
||||
function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:Order;customer?:Customer;couriers:Courier[];canEdit:boolean;onClose:()=>void;onEdit:()=>void}) {
|
||||
const uiText=useUiText("admin/menu.php");
|
||||
|
||||
const [labelOpen,setLabelOpen]=useState(false);
|
||||
const [detailOptions,setDetailOptions]=useState<{stationNumbers:boolean;displayNumber?:string}|null>(null),[detailOptionsError,setDetailOptionsError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();setDetailOptions(null);setDetailOptionsError('');request<{stationNumbers:boolean;displayNumber?:string}>(`/orders/${o.id}/detail-options`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setDetailOptions(v);}).catch(e=>{if(!c.signal.aborted)setDetailOptionsError(e.message);});return()=>c.abort();},[o.id,o.version]);
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape" && !labelOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", key);
|
||||
return () => document.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
}, [onClose,labelOpen]);
|
||||
const [tab, setTab] = useState("details");
|
||||
const [history, setHistory] = useState<{
|
||||
order: Order;
|
||||
@@ -1707,18 +1707,19 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
return (
|
||||
<div
|
||||
className="drawer-backdrop"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && onClose()}
|
||||
onMouseDown={(e) => e.target === e.currentTarget && !labelOpen && onClose()}
|
||||
>
|
||||
<aside
|
||||
className="detail-drawer"
|
||||
className={tab === "mail" ? "detail-drawer detail-drawer-mail" : "detail-drawer"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Auftrag ${o.number}`}
|
||||
aria-label={`Auftrag ${detailOptions?.displayNumber||o.number}`}
|
||||
>
|
||||
<div className="drawer-heading">
|
||||
<div>
|
||||
<span className="eyebrow">AUFTRAGSDETAILS</span>
|
||||
<h2>{o.number}</h2>
|
||||
<span className="eyebrow">{uiText("AUFTRAGSDETAILS")}</span>
|
||||
<h2>{detailOptions?.displayNumber||o.number}</h2>
|
||||
{detailOptions?.displayNumber&&detailOptions.displayNumber!==o.number&&<span className="form-hint">Auftrags-ID {o.id}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
@@ -1735,12 +1736,14 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
</span>
|
||||
</div>
|
||||
<div className="drawer-tabs">
|
||||
{canEdit&&<button className={tab === "mail" ? "active" : ""} onClick={() => setTab("mail")}>Auftragsmail</button>}
|
||||
<button className={tab === "labels" ? "active" : ""} onClick={() => setTab("labels")}>Etiketten</button>
|
||||
<button className={tab === "evidence" ? "active" : ""} onClick={() => setTab("evidence")}>Abliefernachweise</button>
|
||||
<button
|
||||
className={tab === "details" ? "active" : ""}
|
||||
onClick={() => setTab("details")}
|
||||
>
|
||||
Übersicht
|
||||
</button>
|
||||
{uiText("Übersicht")}</button>
|
||||
<button
|
||||
className={tab === "history" ? "active" : ""}
|
||||
onClick={() => setTab("history")}
|
||||
@@ -1752,7 +1755,7 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
{tab === "details" ? (
|
||||
{tab === "mail" ? <OrderDetailMail key={o.id} order={o.id}/> : tab === "labels" ? <CustomerOrderLabels key={o.id} job={o.id} central onPreviewChange={setLabelOpen}/> : tab === "evidence" ? <OrderDeliveryEvidence key={o.id} order={o.id}/> : tab === "details" ? (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
<span className="section-label">KUNDE / ZAHLER</span>
|
||||
@@ -1766,7 +1769,7 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
<strong>{o.reference || "–"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Kostenstelle</span>
|
||||
<span>{uiText("Kostenstelle")}</span>
|
||||
<strong>{o.costCenter || "–"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1789,6 +1792,7 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
<span className="section-label">
|
||||
TRANSPORTSTATIONEN <span>{o.stops.length}</span>
|
||||
</span>
|
||||
{detailOptionsError&&<p className="alert error" role="alert">Anzeigevorgaben: {detailOptionsError}</p>}
|
||||
<div className="stops-timeline">
|
||||
{o.stops.map((s, i) => (
|
||||
<div
|
||||
@@ -1796,16 +1800,18 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
key={s.id || i}
|
||||
>
|
||||
<span className="timeline-marker">
|
||||
{s.completedAt ? <Check size={13} /> : i + 1}
|
||||
{s.completedAt ? <Check size={13} /> : detailOptions?.stationNumbers ? i + 1 : ""}
|
||||
</span>
|
||||
<div>
|
||||
<div className="card-line">
|
||||
<span className="stop-kind">
|
||||
{s.type === "PICKUP"
|
||||
? "Abholung"
|
||||
? uiText("Abholung")
|
||||
: s.type === "BOTH"
|
||||
? "Abholung & Zustellung"
|
||||
: "Zustellung"}
|
||||
: s.type === "NONE"
|
||||
? "Weder Abholung noch Zustellung"
|
||||
: "Zustellung"}
|
||||
</span>
|
||||
{s.completedAt && (
|
||||
<span className="success-text">
|
||||
@@ -1899,7 +1905,7 @@ function OrderDetails({order:o,customer,couriers,canEdit,onClose,onEdit}:{order:
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="history-list" aria-label="Auftragshistorie">
|
||||
<div className="history-list" aria-label={uiText("Auftragshistorie")}>
|
||||
{currentHistory?.error ? (
|
||||
<div className="history-error">
|
||||
<div className="alert error" role="alert">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Modal} from './FormElements';
|
||||
import {MasterDataEditor} from './BusinessForms';
|
||||
import {Statistics} from './Statistics';
|
||||
type Context={id:number;number:string;label:string;headquartersId:number;canOpenMaster:boolean;canStatistics:boolean;masterUnavailableReason:string};
|
||||
export function AppointmentCustomerNavigation({customer,kind,onClose,onDirtyChange}:{customer:number;kind:'master'|'statistics';onClose:()=>void;onDirtyChange:(dirty:boolean)=>void}){
|
||||
const [data,setData]=useState<Context|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{if(customer===0)return;let active=true;request<Context>(`/appointments/customers/${customer}/context`).then(d=>{if(active)setData(d);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[customer]);
|
||||
// The existing master editor owns save/uncertain-response and discard handling.
|
||||
// Keep the outer calendar/workspace from unmounting an open editor silently.
|
||||
const editing=kind==='master'&&(customer===0||!!data?.canOpenMaster);
|
||||
useEffect(()=>{onDirtyChange(editing);return()=>onDirtyChange(false);},[editing,onDirtyChange]);
|
||||
if(editing)return <MasterDataEditor kind="customers" id={customer} onClose={onClose} onSaved={()=>{}}/>;
|
||||
return <Modal title={kind==='statistics'?'Kundenstatistik im Vertrieb':'Kundenstamm im Vertrieb'} wide onClose={onClose}><div className="admin-dialog-body">{error?<p role="alert" className="alert error">{error}</p>:!data?<p role="status">Kunde wird geladen …</p>:kind==='master'?<p>{data.masterUnavailableReason}</p>:!data.canStatistics?<p>Für diese Ansicht ist das Statistikrecht erforderlich.</p>:!data.number?<p>Beim Kunden fehlt die Kundennummer für die Statistik.</p>:<Statistics initialCustomer={{number:data.number,label:data.label,headquartersId:data.headquartersId,from:'2004-01-01'}}/>}</div></Modal>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { request, post, put } from "./api";
|
||||
@@ -11,7 +12,7 @@ export type AppointmentOptions = {
|
||||
categories: Record<string, AppointmentOption[]>;
|
||||
people: (AppointmentOption & { headquarters: string; headquartersId: number })[];
|
||||
groups: (AppointmentOption & { members: number[] })[];
|
||||
defaultMonitoring: number; miscType: string; canReadReports: boolean;
|
||||
defaultMonitoring: number; miscType: string; canReadReports: boolean; showCustomerReports?: boolean; canStatistics?: boolean; canCreateCustomer?: boolean;
|
||||
};
|
||||
export type Appointment = {
|
||||
id: number; headquarters: string; author: string; text: string; startsAt: string; endsAt: string | null;
|
||||
@@ -44,6 +45,8 @@ function EditorForm({ appointment: existing, seed, options, onClose, onSaved, on
|
||||
appointment?: Appointment; seed?: AppointmentSeed; options: AppointmentOptions;
|
||||
onClose: () => void; onSaved: (id: number) => void | Promise<void>; onDirtyChange: (dirty: boolean) => void;
|
||||
}) {
|
||||
const uiText=useUiText();
|
||||
|
||||
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)) {
|
||||
@@ -86,7 +89,7 @@ function EditorForm({ appointment: existing, seed, options, onClose, onSaved, on
|
||||
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>}
|
||||
{existing && <p>{existing.author} · {existing.headquarters} · {existing.participants.filter(p => p.confirmed).length} {uiText(" 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>)}
|
||||
@@ -101,6 +104,6 @@ function EditorForm({ appointment: existing, seed, options, onClose, onSaved, on
|
||||
</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>
|
||||
<div className="admin-row-actions"><button className="button" type="button" disabled={busy} onClick={() => void close()}>{editable ? "Abbrechen" : uiText("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>}</>;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
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 uiText=useUiText();
|
||||
|
||||
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>;
|
||||
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}>{uiText("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>{uiText("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>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { post, request } from "./api";
|
||||
@@ -6,6 +7,7 @@ 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";
|
||||
import { AppointmentCustomerNavigation } from "./AppointmentCustomerNavigation";
|
||||
|
||||
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 };
|
||||
@@ -28,6 +30,8 @@ const onDay = (items: Appointment[], date: string) => items.filter(a => a.starts
|
||||
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 uiText=useUiText();
|
||||
|
||||
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);
|
||||
@@ -38,10 +42,11 @@ export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDi
|
||||
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 [customerAction,setCustomerAction]=useState<{id:number;kind:"master"|"statistics"}|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);
|
||||
setBusy(true); setError(""); setNotice(""); setMail(null); setCustomerAction(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);
|
||||
@@ -95,7 +100,7 @@ export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDi
|
||||
}
|
||||
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>
|
||||
<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?.canCreateCustomer&&<button className="button" disabled={busy} onClick={()=>setCustomerAction({id:0,kind:"master"})}>Neuer Kunde</button>}{options?.canReadReports && options.showCustomerReports !== false && <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}>
|
||||
@@ -104,8 +109,9 @@ export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDi
|
||||
</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>
|
||||
<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="">{uiText("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} />
|
||||
{customer&&<div className="admin-row-actions"><button type="button" className="button" onClick={()=>setCustomerAction({id:customer.id,kind:"master"})}>Kundenstamm öffnen</button>{options.canStatistics&&<button type="button" className="button" onClick={()=>setCustomerAction({id:customer.id,kind:"statistics"})}>Kundenstatistik öffnen</button>}</div>}
|
||||
<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>
|
||||
@@ -117,13 +123,14 @@ export function Appointments({ refresh, onDirtyChange }: { refresh: number; onDi
|
||||
{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></>}
|
||||
{stats && <><div className="admin-title"><h3>Terminstatistik · {stats.total} {stats.total === 1 ? "Termin" : "Termine"}</h3><span>{stats.from} {uiText(" 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>}
|
||||
{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 ? uiText("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.customerId&&<><button className="button" onClick={()=>{setCustomerAction({id:details.customerId!,kind:"master"});setDetails(null);}}>Kundenstamm öffnen</button>{options.canStatistics&&<button className="button" onClick={()=>{setCustomerAction({id:details.customerId!,kind:"statistics"});setDetails(null);}}>Kundenstatistik ö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" })}>{uiText("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} />}
|
||||
{customerAction&&<AppointmentCustomerNavigation customer={customerAction.id} kind={customerAction.kind} onDirtyChange={onDirtyChange} onClose={()=>setCustomerAction(null)}/>}
|
||||
{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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {WarehouseInventory} from './WarehouseInventory';
|
||||
import {MasterListColumnsDialog} from './MasterListColumns';
|
||||
import {request,post,ApiError} from './api';
|
||||
type Row={id:number;eid:string;name:string;additional:string;description:string;barcode:string;serial:number;mountable:number;visible:number;enabled:number;created:string;atg_data:string|null};
|
||||
type Values={eid:string;name:string;additional:string;description:string;barcode:string;serial:boolean;mountable:boolean;packages:number;packageCode:string;stockItems:number;stockPlaces:number;group:number;enabled:boolean;visible:boolean};
|
||||
type Group={id:number;code:string;name:string;headquarters:number};
|
||||
type Options={headquarters:number;branches:{id:number;label:string}[];groups:Group[];settings:Record<string,string>;suggestedEid:string};
|
||||
type Detail={id:number;headquarters:number;values:Values;revision:string;settings:Record<string,string>;groups:Group[];bundle:{id:number;eid:string;name:string;quantity:number}[];groupAmbiguous:boolean;groupShared:boolean};
|
||||
type Command={requestId:string;id:number;action:string;values:Values|null;childEid:string;quantity:number;revision:string;planRevision:string};
|
||||
type Preview={planRevision:string;changes:{field:string;before:unknown;after:unknown}[];action:string};
|
||||
type ArticleList={rows:Row[];hasMore:boolean;columns:string[];definitions:{key:string;label:string}[];warning:string};
|
||||
const ignoreDirty=()=>{};
|
||||
const base='/administration/articles';
|
||||
const labels:Record<string,string>={at_eid:'ExtID',at_name:'Bezeichnung',at_match:'Zusatz',at_description:'Beschreibung',at_barcode:'Barcode',at_serialno:'Seriennummern',at_mountable:'Montierbar',at_bundlequantity:'Verpackungsstücke',at_bundlecode:'Gebindecode',at_stk_itemquantity:'Lagereinheit (Stück)',at_stk_areaquantity:'Lagerplätze',at_authenticated:'Freigegeben',at_visible:'Sichtbar',group:'Warengruppe',eid:'Bestandteil',quantity:'Gesamtmenge'};
|
||||
const unknown=(e:unknown)=>!(e instanceof ApiError)||e.status===0||e.status>=500;
|
||||
export function ArticleAdministration({refresh,onDirtyChange,portal=false}:{refresh:number;onDirtyChange:(dirty:boolean)=>void;portal?:boolean}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<Options|null>(null),[hq,setHq]=useState(0),[filters,setFilters]=useState({eid:'',name:'',additional:'',description:'',barcode:''}),[contains,setContains]=useState(false),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[query,setQuery]=useState(''),[reload,setReload]=useState(0),[list,setList]=useState<ArticleList|null>(null),[error,setError]=useState(''),[editor,setEditor]=useState<number|null>(null),[history,setHistory]=useState<Row|null>(null),[groups,setGroups]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[sort,setSort]=useState('at_name'),[serialArticle,setSerialArticle]=useState<Row|null>(null);
|
||||
useEffect(()=>{onDirtyChange(editor!==null||groups||layoutOpen);return()=>onDirtyChange(false);},[editor,groups,layoutOpen,onDirtyChange]);
|
||||
useEffect(()=>{const c=new AbortController();setError('');request<Options>(base+'/options'+(hq?`?headquarters=${hq}`:''),{signal:c.signal}).then(d=>{if(!c.signal.aborted){setOptions(d);if(!hq)setHq(d.headquarters);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[hq,refresh,reload]);
|
||||
useEffect(()=>{if(!hq)return;const c=new AbortController();setList(null);setError('');request<ArticleList>(base+`?headquarters=${hq}&offset=${offset}&sort=${sort}&`+query,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setList(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[hq,offset,query,sort,refresh,reload]);
|
||||
const locked=editor!==null||groups||!!history||layoutOpen||!!serialArticle;
|
||||
function cell(key:string,r:Row){if(key==='at_id_edit')return <button className="button" disabled={locked} onClick={()=>setEditor(r.id)}>{uiText("Bearbeiten")}</button>;if(key==='at_id_history')return <button className="button" disabled={locked} onClick={()=>setHistory(r)}>{uiText("Historie")}</button>;if(key==='at_name')return <>{r.name}{(!r.enabled||!r.visible)&&<small> · {[!r.enabled&&'Gesperrt',!r.visible&&'Ausgeblendet'].filter(Boolean).join(' · ')}</small>}</>;if(key==='at_serialno')return r.serial?<button className="button" disabled={locked} onClick={()=>setSerialArticle(r)}>Seriennummern</button>:'Nein';if(key==='at_mountable')return r.mountable?'Ja':'Nein';const field={at_eid:'eid',at_match:'additional',at_barcode:'barcode',at_createtime:'created',atg_data:'atg_data',at_description:'description'}[key] as keyof Row;return key==='at_description'&&r.description?<details><summary>Beschreibung</summary><p style={{whiteSpace:'pre-wrap'}}>{r.description}</p></details>:String(r[field]??'–');}
|
||||
return <><div className="admin-title"><div><h2>{uiText("Artikel")}</h2><p>Artikelstammdaten, Warengruppen und Gebindebestandteile.</p></div><button className="button primary" disabled={!options||locked} onClick={()=>setEditor(0)}>{uiText("Neuer Artikel")}</button></div>{error&&<p className="alert error" role="alert">{error}</p>}{serialArticle&&<Modal title={`Seriennummern · ${serialArticle.eid}`} wide onClose={()=>setSerialArticle(null)}><div className="admin-dialog-body modal-body vehicle-planning"><WarehouseInventory stock={0} article={serialArticle.id} exportAllowed={!portal}/></div><div className="modal-footer"><button className="button" onClick={()=>setSerialArticle(null)}>{uiText("Schließen")}</button></div></Modal>}
|
||||
<form onSubmit={e=>{e.preventDefault();setOffset(0);setQuery(new URLSearchParams({...filters,contains:String(contains),hidden:String(hidden)}).toString());setReload(n=>n+1);}}><fieldset disabled={locked}><div className="admin-toolbar"><Field label="Niederlassung"><select value={hq} onChange={e=>{setHq(Number(e.target.value));setOffset(0);}}>{options?.branches.map(b=><option key={b.id} value={b.id}>{b.label}</option>)}</select></Field>{Object.entries(filters).map(([key,value])=><Field key={key} label={{eid:'ExtID',name:'Bezeichnung',additional:'Zusatz',description:'Beschreibung',barcode:'Barcode'}[key]||key}><input value={value} maxLength={100} onChange={e=>setFilters(f=>({...f,[key]:e.target.value}))}/></Field>)}</div><div className="admin-toolbar"><label className="admin-check"><input type="checkbox" checked={contains} onChange={e=>setContains(e.target.checked)}/>Teilwortsuche</label><label className="admin-check"><input type="checkbox" checked={hidden} onChange={e=>setHidden(e.target.checked)}/>{uiText("Ausgeblendete anzeigen")}</label><button className="button">{uiText("Suchen")}</button>{!portal&&<><button type="button" className="button" onClick={()=>setGroups(true)}>Warengruppen verwalten</button><button type="button" className="button" onClick={()=>setLayoutOpen(true)}>Listenspalten</button></>}</div></fieldset></form>
|
||||
{list?.warning&&<p className="alert info">{list.warning}</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr>{list?.columns.map((key,i)=><th key={i}>{['at_id_history','at_id_edit'].includes(key)?list.definitions.find(d=>d.key===key)?.label:<button className="button" disabled={locked} onClick={()=>{setSort(key);setOffset(0);}}>{list.definitions.find(d=>d.key===key)?.label}{sort===key?' ↑':''}</button>}</th>)}</tr></thead><tbody>{list?.rows.map(r=><tr key={r.id}>{list.columns.map((key,i)=><td key={i}>{cell(key,r)}</td>)}</tr>)}</tbody></table></div>{list&&!list.rows.length&&<p>Keine Artikel gefunden.</p>}{!list&&!error&&<p>Artikel werden geladen …</p>}<div className="admin-toolbar"><button className="button" disabled={locked||offset===0} onClick={()=>setOffset(n=>n-50)}>Zurück</button><span>Seite {offset/50+1}</span><button className="button" disabled={locked||!list?.hasMore} onClick={()=>setOffset(n=>n+50)}>Weiter</button></div>
|
||||
{layoutOpen&&<MasterListColumnsDialog kind="articles" onClose={()=>{setLayoutOpen(false);setReload(n=>n+1);}} onDirtyChange={ignoreDirty}/>}
|
||||
{editor!==null&&<ArticleEditor id={editor} headquarters={hq} onClose={()=>{setEditor(null);setReload(n=>n+1);}}/>}{groups&&<ArticleGroupEditor headquarters={hq} onClose={()=>{setGroups(false);setReload(n=>n+1);}}/>}{history&&<ArticleHistory row={history} headquarters={hq} onClose={()=>setHistory(null)}/>}</>;
|
||||
}
|
||||
function ArticleEditor({id,headquarters,onClose}:{id:number;headquarters:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<Detail|null>(null),[draft,setDraft]=useState<Values|null>(null),[article,setArticle]=useState(id),[preview,setPreview]=useState<Preview|null>(null),[ack,setAck]=useState(false),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[child,setChild]=useState(''),[quantity,setQuantity]=useState('1'),[notice,setNotice]=useState('');
|
||||
const [discard,dialog]=useDiscardConfirmation(),sending=useRef(false),command=useRef<Command|null>(null),scope=`?headquarters=${headquarters}`;
|
||||
const dirty=!!data&&JSON.stringify(draft)!==JSON.stringify(data.values),locked=busy||uncertain||stale;
|
||||
function display(field:string,value:unknown){if(value===null||value===undefined||value==='')return '–';if(['at_serialno','at_mountable','at_authenticated','at_visible'].includes(field))return Number(value)===1?'Ja':'Nein';if(field==='group')return Number(value)===0?'Keine Warengruppe':data?.groups.find(g=>g.id===Number(value))?.name||`#${value}`;return String(value);}
|
||||
function invalidate(){setPreview(null);setAck(false);command.current=null;setError('');setNotice('');}
|
||||
function apply(d:Detail){setData(d);setDraft(d.values);setStale(false);invalidate();}
|
||||
useEffect(()=>{const c=new AbortController();request<Detail>(base+`/${article}`+scope,{signal:c.signal}).then(d=>{if(!c.signal.aborted)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[article,scope]);
|
||||
async function close(){if(!sending.current&&!uncertain&&(!dirty||await discard('Ungespeicherte Artikeldaten verwerfen?')))onClose();}
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Artikeldaten verwerfen?'))return;sending.current=true;setBusy(true);try{apply(await request<Detail>(base+`/${article}`+scope));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
function edit<K extends keyof Values>(key:K,value:Values[K]){setDraft(v=>v?{...v,[key]:value}:v);invalidate();}
|
||||
async function suggest(){if(sending.current||locked)return;sending.current=true;setBusy(true);try{const o=await request<Options>(base+'/options'+scope);if(!o.suggestedEid)throw new Error('Kein Nummernvorschlag konfiguriert.');edit('eid',o.suggestedEid);}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function prepare(action:string,eid=child){if(!data||!draft||sending.current||locked)return;sending.current=true;setBusy(true);invalidate();try{const c:Command={requestId:crypto.randomUUID(),id:article,action,values:action==='SAVE'?draft:null,childEid:eid,quantity:Number(quantity),revision:data.revision,planRevision:''};const p=await post<Preview>(base+'/preview'+scope,c);command.current={...c,planRevision:p.planRevision};setPreview(p);}catch(e){setError((e as Error).message);if(e instanceof ApiError&&e.status===409)setStale(true);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!command.current||sending.current||!ack||stale)return;sending.current=true;setBusy(true);setError('');try{const result=await post<{id:number}>(base+'/save'+scope,command.current);setUncertain(false);command.current=null;setPreview(null);setAck(false);setArticle(result.id);try{apply(await request<Detail>(base+`/${result.id}`+scope));setNotice('Artikel gespeichert.');setChild('');setQuantity('1');}catch(e){setStale(true);setError('Gespeichert. Der neue Stand konnte noch nicht geladen werden: '+(e as Error).message);}}catch(e){setError((e as Error).message);setUncertain(unknown(e));setStale(!unknown(e));}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title={article?`Artikel #${article}`:uiText("Neuer Artikel")} wide onClose={()=>void close()}><div className="admin-dialog-body modal-body vehicle-planning">{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p role="status" className="admin-success">{notice}</p>}{!data&&!error&&<p>Artikel wird geladen …</p>}
|
||||
{data&&draft&&<><fieldset disabled={locked} className="business-fieldset"><legend>Artikelstammdaten</legend><div className="admin-toolbar"><Field label={uiText("Bezeichnung")}><input required maxLength={50} value={draft.name} onChange={e=>edit('name',e.target.value)}/></Field><Field label={uiText("ExtID")}><input required maxLength={20} disabled={article>0&&data.settings.AT_EID_EDITABLE!=='1'} value={draft.eid} onChange={e=>edit('eid',e.target.value)}/></Field>{article===0&&data.settings.AT_EID_GENERATION&&<button className="button" onClick={()=>void suggest()}>Nummer vorschlagen</button>}<Field label={uiText("Zusatz")}><input maxLength={100} value={draft.additional} onChange={e=>edit('additional',e.target.value)}/></Field><Field label="Barcode"><input maxLength={50} value={draft.barcode} onChange={e=>edit('barcode',e.target.value)}/></Field></div><Field label="Beschreibung"><textarea rows={4} value={draft.description} onChange={e=>edit('description',e.target.value)}/></Field><div className="admin-toolbar"><Field label="Warengruppe"><select value={draft.group} disabled={data.groupAmbiguous} onChange={e=>edit('group',Number(e.target.value))}><option value={0} disabled={data.groupShared}>Keine Warengruppe</option>{data.groups.map(g=><option key={g.id} value={g.id}>{g.code} · {g.name}{g.headquarters===0?' (gemeinsam)':''}</option>)}</select></Field>{data.groupAmbiguous&&<p className="alert error">Mehrdeutige Warengruppenzuordnung im Bestand.</p>}{data.groupShared&&<p>Die bestehende Zuordnung gilt gemeinsam für mehrere Niederlassungen.</p>}
|
||||
{(['serial','mountable','enabled','visible'] as const).map(k=><label className="admin-check" key={k}><input type="checkbox" checked={draft[k]} onChange={e=>edit(k,e.target.checked)}/>{{serial:'Seriennummern führen',mountable:'Montierbar',enabled:'Freigegeben',visible:'Sichtbar'}[k]}</label>)}</div><div className="admin-toolbar">{(['packages','stockItems','stockPlaces'] as const).map(k=><Field key={k} label={{packages:'Verpackungsstücke',stockItems:'Lagereinheit (Stück)',stockPlaces:'Benötigte Lagerplätze'}[k]}><input type="number" min={k==='packages'?0:1} max={k==='stockPlaces'?127:k==='packages'?32767:8388607} step={1} value={draft[k]} onChange={e=>edit(k,Number(e.target.value))}/></Field>)}<Field label="Gebindecode"><input maxLength={50} value={draft.packageCode} onChange={e=>edit('packageCode',e.target.value)}/></Field></div><p>{draft.stockItems} Stück benötigen {draft.stockPlaces} Lagerplätze.</p><button className="button primary" disabled={!dirty||!draft.name.trim()||!draft.eid.trim()} onClick={()=>void prepare('SAVE')}>Änderungen prüfen</button></fieldset>
|
||||
{article>0&&<fieldset className="business-fieldset" disabled={locked||dirty}><legend>Gebindebestandteile</legend>{dirty&&<p>Zuerst die Stammdaten speichern oder zurücksetzen.</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("ExtID")}</th><th>{uiText("Bezeichnung")}</th><th>Menge</th><th>{uiText("Aktion")}</th></tr></thead><tbody>{data.bundle.map(b=><tr key={b.id}><td>{b.eid||'–'}</td><td>{b.name}</td><td>{b.quantity}</td><td><button className="button" disabled={!b.eid} onClick={()=>void prepare('BUNDLE_REMOVE',b.eid)}>Entfernen prüfen</button></td></tr>)}</tbody></table></div>{!data.bundle.length&&<p>Noch keine Gebindebestandteile.</p>}<div className="admin-toolbar"><Field label="ExtID des Bestandteils"><input value={child} maxLength={20} onChange={e=>{setChild(e.target.value);invalidate();}}/></Field><Field label="Zusätzliche Menge"><input type="number" min={1} max={32767} step={1} value={quantity} onChange={e=>{setQuantity(e.target.value);invalidate();}}/></Field><button className="button" disabled={!child.trim()||Number(quantity)<1} onClick={()=>void prepare('BUNDLE_ADD')}>Hinzufügen prüfen</button></div><p>Bei bereits zugeordneten Bestandteilen wird die Menge addiert. Lagerbestände werden hier nicht gebucht.</p></fieldset>}
|
||||
{preview&&<section className="customer-settings-preview"><h3>Änderungen · Vorschau</h3>{preview.action!=='SAVE'&&<p>Bestandteil: <strong>{command.current?.childEid}</strong> · {preview.action==='BUNDLE_REMOVE'?'Entfernen':'Menge hinzufügen'}</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Feld</th><th>Bisher</th><th>Danach</th></tr></thead><tbody>{preview.changes.map(c=><tr key={c.field}><td>{labels[c.field]||c.field}</td><td style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{display(c.field,c.before)}</td><td style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{display(c.field,c.after)}</td></tr>)}</tbody></table></div><label className="admin-check"><input type="checkbox" checked={ack} disabled={locked} onChange={e=>setAck(e.target.checked)}/>Änderungen geprüft</label></section>}</>}
|
||||
{uncertain&&<p className="alert info" role="status">Speicherergebnis noch unbekannt. Bitte denselben Vorgang erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button>{preview&&<button className="button primary" disabled={busy||stale||!ack} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':uiText("Änderungen speichern")}</button>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
function ArticleHistory({row,headquarters,onClose}:{row:Row;headquarters:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
type Entry={id?:number;at:string;operation:number;actor:number;description:string};const [offset,setOffset]=useState(0),[data,setData]=useState<{rows:Entry[];pending:Entry[];hasMore:boolean}|null>(null),[error,setError]=useState('');useEffect(()=>{const c=new AbortController();setData(null);setError('');request<typeof data>(base+`/${row.id}/history?headquarters=${headquarters}&offset=${offset}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[row.id,headquarters,offset]);return <Modal title={`Artikelhistorie · ${row.eid}`} wide onClose={onClose}><div className="admin-dialog-body modal-body vehicle-planning">{error&&<p className="alert error">{error}</p>}{data&&[...data.pending,...data.rows].map((e,i)=><article className="business-fieldset" key={i}><strong>{e.at} · Vorgang {e.operation} · Benutzer {e.actor}</strong><p style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{e.description}</p></article>)}{data&&!data.rows.length&&!data.pending.length&&<p>Keine Historieneinträge vorhanden.</p>}</div><div className="modal-footer"><button className="button" disabled={!offset} onClick={()=>setOffset(n=>n-50)}>Zurück</button><span>Seite {offset/50+1}</span><button className="button" disabled={!data?.hasMore} onClick={()=>setOffset(n=>n+50)}>Weiter</button><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
function ArticleGroupEditor({headquarters,onClose}:{headquarters:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
type G={id:number;code:string;name:string;shared:boolean;revision:string};const blank:G={id:0,code:'',name:'',shared:false,revision:''};const [rows,setRows]=useState<G[]>([]),[draft,setDraft]=useState<G>(blank),[original,setOriginal]=useState<G>(blank),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[notice,setNotice]=useState('');const sending=useRef(false),command=useRef<(G&{requestId:string})|null>(null),[discard,dialog]=useDiscardConfirmation();const url=`/administration/article-groups?headquarters=${headquarters}`,dirty=JSON.stringify(draft)!==JSON.stringify(original);useEffect(()=>{const c=new AbortController();request<{rows:G[]}>(url,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setRows(d.rows);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[url]);async function choose(g:G){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Warengruppe verwerfen?'))return;setDraft(g);setOriginal(g);setError('');setNotice('');command.current=null;}async function close(){if(!sending.current&&!uncertain&&(!dirty||await discard('Ungespeicherte Warengruppe verwerfen?')))onClose();}async function save(){if(sending.current)return;sending.current=true;setBusy(true);setError('');command.current??={...draft,requestId:crypto.randomUUID()};try{await post(url,command.current);setUncertain(false);command.current=null;setOriginal(draft);setNotice('Warengruppe gespeichert.');try{setRows((await request<{rows:G[]}>(url)).rows);setDraft(blank);setOriginal(blank);}catch(e){setError('Gespeichert. Bitte die Warengruppen erneut öffnen: '+(e as Error).message);}}catch(e){setError((e as Error).message);setUncertain(unknown(e));if(!unknown(e))command.current=null;}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title="Warengruppen" wide onClose={()=>void close()}><div className="admin-dialog-body modal-body vehicle-planning">{error&&<p className="alert error">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}<fieldset disabled={busy||uncertain}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Schlüssel")}</th><th>{uiText("Bezeichnung")}</th><th>Geltung</th><th>{uiText("Aktion")}</th></tr></thead><tbody>{rows.map(g=><tr key={g.id}><td>{g.code}</td><td>{g.name}</td><td>{g.shared?'Alle Niederlassungen':'Gewählte Niederlassung'}</td><td><button className="button" onClick={()=>void choose(g)}>{uiText("Bearbeiten")}</button></td></tr>)}</tbody></table></div><button className="button" onClick={()=>void choose(blank)}>Neue Warengruppe</button><div className="admin-toolbar"><Field label={uiText("Schlüssel")}><input maxLength={10} disabled={draft.id>0} value={draft.code} onChange={e=>setDraft(d=>({...d,code:e.target.value}))}/></Field><Field label="Bezeichnung der Warengruppe"><input maxLength={100} value={draft.name} onChange={e=>setDraft(d=>({...d,name:e.target.value}))}/></Field><label className="admin-check"><input type="checkbox" checked={draft.shared} onChange={e=>setDraft(d=>({...d,shared:e.target.checked}))}/>Für alle Niederlassungen des Mandanten</label></div></fieldset>{uncertain&&<p className="alert info">Speicherbestätigung fehlt. Bitte denselben Vorgang erneut abrufen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button primary" disabled={busy||!dirty||!draft.code.trim()||!draft.name.trim()} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Warengruppe speichern'}</button></div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function AssignmentDriver({value}:{value:{courierId:number;ownerCourierId:number;driverName:string}}){
|
||||
return <span style={{display:"block"}}>{value.driverName&&<>Fahrer: <strong>{value.driverName}</strong></>}{value.courierId>0&&value.ownerCourierId>0&&value.courierId!==value.ownerCourierId&&<span className="form-hint" style={{display:"block"}}>Angemeldet auf einem Fahrzeug eines anderen Halters</span>}</span>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Modal} from './FormElements';
|
||||
import type {Order} from './types';
|
||||
|
||||
type RunningOrder={id:number;at:string;status:number;longhaul:boolean;headquarters:string;canOpen:boolean;occupied:boolean;stations:{id:number;position:number;postalCode:string;completed:boolean}[]};
|
||||
type Result={sid:string;total:number;page:number;pageSize:number;items:RunningOrder[]};
|
||||
const time=(v:string|null|undefined)=>{if(!v)return '–';const date=new Date(v.replace(' ','T'));return Number.isNaN(date.getTime())?v:date.toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'});};
|
||||
|
||||
export function AssignmentOrders({job,vehicle,courier,sid,refresh}:{job:number;vehicle:number;courier:number;sid:string;refresh:number}){
|
||||
const [open,setOpen]=useState(false),[page,setPage]=useState(1),[reload,setReload]=useState(0),[result,setResult]=useState<Result|null>(null),[error,setError]=useState(''),[loading,setLoading]=useState(false),[detail,setDetail]=useState<number|null>(null);
|
||||
useEffect(()=>{setOpen(false);setPage(1);setResult(null);setDetail(null);},[job,vehicle,courier,refresh]);
|
||||
useEffect(()=>{if(!open)return;const c=new AbortController();setLoading(true);setResult(null);setError('');request<Result>(`/orders/${job}/courier-assignment/vehicles/${vehicle}/orders?courierId=${courier}&page=${page}`,{signal:c.signal}).then(r=>{if(!c.signal.aborted)setResult(r);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[job,vehicle,courier,refresh,open,page,reload]);
|
||||
return <div className="assignment-orders"><button type="button" className="button" aria-expanded={open} onClick={()=>setOpen(v=>!v)}>{open?'Aufträge ausblenden':`Aufträge von ${sid} anzeigen`}</button>
|
||||
{open&&<section aria-label={`Laufende Aufträge von ${sid}`}>
|
||||
{loading&&<p role="status">Aufträge und Stationsfolge werden geladen …</p>}
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}
|
||||
<button type="button" className="button" disabled={loading} onClick={()=>setReload(v=>v+1)}>Auftragsstand aktualisieren</button>
|
||||
{result&&<><p>{result.total} laufende / vorgemerkte Aufträge</p><ul className="assignment-order-list">{result.items.map(o=><li key={o.id}>
|
||||
<div>{o.canOpen?<button type="button" className="button" onClick={()=>setDetail(o.id)}>Auftrag #{o.id} öffnen</button>:<strong>Auftrag #{o.id}</strong>} {o.longhaul?'BWV · ':''}{o.headquarters}</div>
|
||||
<div>{time(o.at)} · {o.status===0?'Angeboten':'Angenommen'} · {o.occupied?'Belegt im Vorlauffenster':'Außerhalb des Vorlauffensters'}</div>
|
||||
<ol className="assignment-route" aria-label={`Stationsfolge Auftrag ${o.id}`}>{o.stations.map(t=><li key={t.id} className={t.completed?'completed':''}><span>{t.position}. {t.postalCode||'PLZ fehlt'}</span>{t.completed&&<span className="assignment-stop-state"> ✓ erledigt</span>}</li>)}</ol>
|
||||
{!o.canOpen&&<p className="form-hint">Auftrag einer anderen Niederlassung. Vollständige Details sind hier nicht freigegeben.</p>}
|
||||
</li>)}</ul>{!result.total&&<p>Derzeit keine weiteren laufenden Aufträge.</p>}
|
||||
{result.total>result.pageSize&&<div className="admin-row-actions"><button type="button" className="button" disabled={page<=1} onClick={()=>setPage(v=>v-1)}>Vorherige Aufträge</button><span>Seite {page} / {Math.ceil(result.total/result.pageSize)}</span><button type="button" className="button" disabled={page*result.pageSize>=result.total} onClick={()=>setPage(v=>v+1)}>Weitere Aufträge</button></div>}
|
||||
</>}
|
||||
</section>}{detail!==null&&<AssignmentOrderDetails id={detail} onClose={()=>setDetail(null)}/>}</div>;
|
||||
}
|
||||
|
||||
function AssignmentOrderDetails({id,onClose}:{id:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [order,setOrder]=useState<Order|null>(null),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setOrder(null);setError('');request<Order>(`/orders/${id}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setOrder(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[id,reload]);
|
||||
return <Modal title={`Auftragsdetails #${id}`} onClose={onClose}><div className="modal-body">
|
||||
{error?<p role="alert" className="alert error">{error}</p>:!order?<p role="status">Auftrag wird geladen …</p>:<>
|
||||
<p><strong>{order.legacyStatusLabel||order.status}</strong> · {order.service} · {order.vehicle}</p>
|
||||
<p>{order.customerName||'Kunde nicht hinterlegt'} · {time(order.pickupAt)}<br/>Referenz: {order.reference||'–'} · Kostenstelle: {order.costCenter||'–'}</p>
|
||||
{!!order.notes&&<p className="pre-line">{order.notes}</p>}
|
||||
<ol className="assignment-detail-stops">{order.stops.map((s,i)=><li key={s.id||i}>
|
||||
<strong>{s.sequence}. {s.name}</strong> · {s.type==='PICKUP'?uiText("Abholung"):s.type==='DELIVERY'?'Zustellung':s.type==='BOTH'?'Abholung und Zustellung':'Station'}
|
||||
<p>{s.street}<br/>{s.postalCode} {s.city}</p>
|
||||
{(s.contact||s.phone)&&<p>{s.contact} {s.phone}</p>}{s.note&&<p className="pre-line">{s.note}</p>}
|
||||
{s.completedAt&&<p>Erledigt: {time(s.completedAt)}{s.recipientName?` · ${s.recipientName}`:''}</p>}
|
||||
</li>)}</ol>
|
||||
</>}
|
||||
</div><div className="modal-footer"><button type="button" className="button" onClick={()=>setReload(v=>v+1)}>Details aktualisieren</button><button type="button" className="button primary" onClick={onClose}>Zurück zur Kurierauswahl</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
type Candidate={courier:number;vehicle:number;sid:string;name:string;postal:string;availableAt:string;group:number;groupName:string;challenges:number;blockers:string[]};
|
||||
type Plan={state:string;message:string;startsAt:string|null;iterations:number;reserved:boolean;onlyFavoured:boolean;ignoreFavoured:boolean;candidates:Candidate[]};
|
||||
type View={workerEnabled:boolean;plan:Plan};
|
||||
export function AutomaticOfferPlan({id,version}:{id:number;version:number}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<View|null>(null),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setView(null);setError('');request<View>(`/orders/${id}/mediation/automatic`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setView(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[id,version,reload]);
|
||||
const p=view?.plan,next=p?.candidates.find(c=>!c.blockers.length);
|
||||
return <section className="business-section"><h3>Automatische Angebotsrunden</h3>{error?<><p role="alert">{error}</p><button type="button" className="button" onClick={()=>setReload(n=>n+1)}>Angebotsrunden erneut laden</button></>:!p?<p role="status">Angebotsrunden werden geprüft …</p>:<>
|
||||
<p>{p.message}</p>{p.startsAt&&<p>Frühester Beginn: <strong>{new Date(p.startsAt).toLocaleString('de-DE')}</strong></p>}
|
||||
{p.state==='READY'&&<><p>{p.reserved?'Es wird ausschließlich der feste Reservierungskurier geprüft.':`${p.ignoreFavoured?'Die Favoritenrunde wird übersprungen.':'Zuerst bevorzugte Kuriere.'} ${p.onlyFavoured?'Es folgen keine Bereichs- oder Nachbarschaftsrunden.':'Danach Vermittlungsbereich und Nachbarschaften.'} Je Stufe bis zu ${p.iterations} Durchläufe.`}</p>
|
||||
{next&&<p>Nächstes geeignetes Angebot: <strong>{next.sid} · {next.name}</strong> · {next.groupName}</p>}
|
||||
{p.candidates.length>0&&<details><summary>{p.candidates.length} Kandidaten und Ausschlussgründe anzeigen</summary><div className="table-scroll"><table className="data-table"><thead><tr><th>{uiText("Kurier")}</th><th>Runde / PLZ</th><th>Bisherige Angebote</th><th>Prüfung</th></tr></thead><tbody>{p.candidates.map((c,i)=><tr key={`${c.courier}-${c.vehicle}-${i}`}><td><strong>{c.sid||`#${c.courier}`}</strong><br/>{c.name}</td><td>{c.groupName}<br/>{c.postal}</td><td>{c.challenges}</td><td>{c.blockers.length?c.blockers.join(' · '):'Geeignet'}</td></tr>)}</tbody></table></div></details>}
|
||||
<p className="form-hint">Die Auswahl wird beim tatsächlichen Angebot erneut geprüft. Diese Anzeige verteilt keine Aufträge.</p></>}
|
||||
{!view!.workerEnabled&&p.state!=='NONE'&&<p className="alert info">Der neue Angebotsdienst ist noch nicht aktiviert. Die Vorschau berücksichtigt die aktuellen Vermittlungsregeln.</p>}
|
||||
</>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {useEffect,useState} from "react";
|
||||
import {request} from "./api";
|
||||
type Item={key:string;event:string;additional:boolean;recipientLabel?:string;state:string;reason:string;recipients:string[];startedAt:string;completedAt:string};
|
||||
type View={enabled:boolean;legacyEnabled:boolean;smtpConfigured:boolean;items:Item[];scope:string};
|
||||
const labels:Record<string,string>={READY:"Versandbereit",WAITING:"Wartet",DISABLED:"Nicht vorgesehen",EXCLUDED:"Ausgeschlossen",DONE:"Bereits verarbeitet / gesperrt",STARTED:"Übergabe offen",SENT:"SMTP bestätigt",UNCERTAIN:"Übergabe unklar",BLOCKED:"Prüfung erforderlich"};
|
||||
export function AutomaticOrderMail({order}:{order:number}){
|
||||
const [open,setOpen]=useState(false),[reload,setReload]=useState(0),[data,setData]=useState<View|null>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{setData(null);setError("");if(!open)return;const abort=new AbortController();setBusy(true);request<View>(`/orders/${order}/automatic-mail`,{signal:abort.signal}).then(v=>{if(!abort.signal.aborted)setData(v);}).catch((e:Error)=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setBusy(false);});return()=>abort.abort();},[order,open,reload]);
|
||||
return <section className="automatic-order-mail" aria-label="Automatische Statusmails"><button className="button" aria-expanded={open} onClick={()=>setOpen(v=>!v)}>{open?"Automatische Statusmails ausblenden":"Automatische Statusmails prüfen"}</button>{open&&<><h4>Automatische Statusmails</h4><p>Diese Ansicht liest den Versandstatus. Beim Öffnen wird keine E-Mail versendet.</p>{busy&&<p role="status">Versandstatus wird geladen …</p>}{error&&<p role="alert" className="alert error">{error}</p>}{data&&<><p className="form-hint">{data.scope}</p><p className="alert">{!data.enabled?"Der native Statusmaildienst ist ausgeschaltet.":data.legacyEnabled?"Der native Dienst wartet auf die Stilllegung des PHP-Automailers.":!data.smtpConfigured?"Der native Dienst wartet auf die SMTP-Konfiguration.":"Der native Statusmaildienst ist freigegeben."}{data.legacyEnabled&&" Der PHP-Automailer ist weiterhin freigegeben."}</p><div className="table-scroll"><table className="data-table"><thead><tr><th>Ereignis</th><th>Empfängerart</th><th>Status</th><th>Empfänger / Prüfung</th></tr></thead><tbody>{data.items.map(item=><tr key={item.key}><td>{item.event}</td><td>{item.recipientLabel||(item.additional?"Zusätzliche Auftragsempfänger":"Kundenvorgabe")}</td><td>{labels[item.state]||item.state}{item.startedAt&&<small className="block">{new Date(item.startedAt).toLocaleString("de-DE")}</small>}</td><td>{item.recipients.length>0&&<div>{item.recipients.join(", ")}</div>}<small>{item.reason}</small></td></tr>)}</tbody></table></div></>}<button className="button" disabled={busy} onClick={()=>setReload(n=>n+1)}>Status neu laden</button></>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from "react";
|
||||
import {request,put} from "./api";
|
||||
import {ErrorMessage,uncertainWrite} from "./BusinessForms";
|
||||
|
||||
type Output={customer:string;courier:string;exportItems:{factor:number;description:string;price:number;total:number}[]};
|
||||
type Preview={revision?:string;fingerprint:string;previous:Output;next:Output;writeCourier:boolean;warnings?:string[];linkedDocuments?:{id:number;courier:string;previous:string;next:string}[]};
|
||||
const euro=(value:number)=>new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(value);
|
||||
function InvoiceOutput({title,data}:{title:string;data:Output}) {
|
||||
const uiText=useUiText();
|
||||
|
||||
return <section className="batch-invoice-output"><h4>{title}</h4><strong>Kundenrechnungstext</strong><pre>{data.customer.trimEnd()||"Kein Rechnungstext"}</pre><strong>Kurierabrechnungstext</strong><pre>{data.courier.trimEnd()||"Kein Rechnungstext"}</pre><table className="admin-table"><thead><tr><th>Faktor / Menge</th><th>{uiText("Leistung")}</th><th>Basis</th><th>{uiText("Betrag")}</th></tr></thead><tbody>{data.exportItems.map((item,i)=><tr key={i}><td>{new Intl.NumberFormat("de-DE",{maximumFractionDigits:10}).format(item.factor)}</td><td>{item.description}</td><td>{euro(item.price)}</td><td>{euro(item.total)}</td></tr>)}</tbody></table>{!data.exportItems.length&&<p>Keine B2B-Rechnungspositionen.</p>}</section>;
|
||||
}
|
||||
export function BatchInvoiceRefresh({id,revision,required,disabled,onSaved,onDirtyChange,onBusyChange,regular=false}:{regular?:boolean;id:number;revision:string;required:boolean;disabled:boolean;onSaved:()=>void;onDirtyChange:(v:boolean)=>void;onBusyChange:(v:boolean)=>void}) {
|
||||
const [preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[error,setError]=useState("");
|
||||
const pending=useRef<object|null>(null);
|
||||
useEffect(()=>{setPreview(null);setConfirmed(false);setError("");},[id,revision]);
|
||||
useEffect(()=>{onDirtyChange(!!preview||busy||uncertain);return()=>onDirtyChange(false);},[preview,busy,uncertain,onDirtyChange]);
|
||||
useEffect(()=>{onBusyChange(busy||uncertain);return()=>onBusyChange(false);},[busy,uncertain,onBusyChange]);
|
||||
async function calculate(){setBusy(true);setError("");setConfirmed(false);pending.current=null;try{setPreview(await request<Preview>(`/orders/${id}/invoice-preparation`));}catch(e){setPreview(null);setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||!confirmed)return;setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),revision:preview.revision||revision,fingerprint:preview.fingerprint,confirmReplace:true};try{await put(`/orders/${id}/invoice-preparation`,pending.current);pending.current=null;setUncertain(false);setPreview(null);setConfirmed(false);onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){pending.current=null;setPreview(null);setConfirmed(false);}setError((e as Error).message+(retry?" Bitte denselben Speichervorgang erneut prüfen.":""));}finally{setBusy(false);}}
|
||||
return <section className="business-section" aria-label={regular?"Rechnungsausgabe des Auftrags":"Rechnungsausgabe der Listenbuchung"}><h3>{regular?"Rechnungsausgabe des Auftrags":"Rechnungsausgabe der Listenbuchung"}</h3><p>{required?"Text oder Preis wurde geändert. Bitte die Rechnungsausgabe aktualisieren.":"Rechnungstexte und B2B-Positionen aus den gespeicherten Auftragsdaten erneut erzeugen."} Auftragsänderungen zuerst speichern; Auftragsbeträge und Zustellnachweise bleiben erhalten.</p><ErrorMessage message={error}/><button type="button" className="button" disabled={disabled||busy||uncertain} onClick={()=>void calculate()}>Rechnungsausgabe prüfen</button>{preview&&<>{preview.warnings?.map(warning=><p className="report-warning" role="note" key={warning}>{warning}</p>)}<div className="batch-invoice-comparison"><InvoiceOutput title="Bisher gespeichert" data={preview.previous}/><InvoiceOutput title="Nach Aktualisierung" data={preview.writeCourier?preview.next:{...preview.next,courier:preview.previous.courier}}/></div>{!!preview.linkedDocuments?.length&&<section className="business-section" aria-label="Verbundene Unternehmerbelege"><h4>Verbundene Unternehmerbelege</h4><p>Diese Rechnungstexte werden zusammen mit dem Hauptauftrag aktualisiert.</p>{preview.linkedDocuments.map(doc=><details key={doc.id}><summary>Beleg #{doc.id} · {doc.courier}</summary><div className="batch-invoice-comparison"><section className="batch-invoice-output"><strong>Bisher gespeichert</strong><pre>{doc.previous.trimEnd()||"Kein Rechnungstext"}</pre></section><section className="batch-invoice-output"><strong>Nach Aktualisierung</strong><pre>{doc.next.trimEnd()}</pre></section></div></details>)}</section>}<p className="form-hint">Positionen gemäß Druckvorgaben; sie bilden keine eigenständige Rechnungssumme.{!preview.writeCourier&&" Der Kurierabrechnungstext bleibt gemäß Druckvorgabe unverändert."}</p><label className="checkbox-label"><input type="checkbox" checked={confirmed} disabled={disabled||busy||uncertain} onChange={e=>setConfirmed(e.target.checked)}/>Angezeigte Rechnungstexte und B2B-Positionen ersetzen, einschließlich manueller Textänderungen.</label><div className="admin-row-actions"><button type="button" className="button primary" disabled={disabled||busy||!confirmed} onClick={()=>void save()}>{uncertain?"Speichervorgang erneut prüfen":"Rechnungsausgabe aktualisieren"}</button><button type="button" className="button" disabled={busy||uncertain} onClick={()=>{setPreview(null);setConfirmed(false);}}>Vorschau verwerfen</button></div></>}</section>;
|
||||
}
|
||||
+31
-14
@@ -1,27 +1,44 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import {PaymentControls} from './PaymentControls';
|
||||
import {InvoiceCsvExport} from "./InvoiceCsvExport";
|
||||
import {InvoiceDocuments} from "./InvoiceDocuments";
|
||||
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 Query={from:string;to:string;mode:string;customer:number|null;center:number|null;descendants:boolean;courier:number|null;invoice:string;unassigned:boolean;vehicleTypes:number[]};
|
||||
type Row={id:number;center:number;centerName:string;customer:string;courier:string;finished:string;invoice:string;invoiceDate:string;invoicePresent:boolean;ordered:string;vehicleType:number;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);
|
||||
export type BillingSeed={customer:number;from:string;to:string};
|
||||
function initialQuery(seed?:BillingSeed):Query{return {from:seed?.from??today().slice(0,8)+"01",to:seed?.to??today(),mode:"CUSTOMER",customer:seed?.customer??null,center:null,descendants:true,courier:null,invoice:"",unassigned:false,vehicleTypes:[]};}
|
||||
export function Billing({refresh,onDirtyChange,seed,onBusyChange}:{refresh:number;onDirtyChange:(value:boolean)=>void;seed?:BillingSeed;onBusyChange?:(value:boolean)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<{customers:Choice[];couriers:Choice[];vehicleTypes:Choice[];canAssign:boolean}|null>(null),[centers,setCenters]=useState<Choice[]>([]),[query,setQuery]=useState<Query>(()=>initialQuery(seed)),[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),[replaceExisting,setReplaceExisting]=useState(false),[review,setReview]=useState(false),[document,setDocument]=useState<{customer:number;number:string}|null>(null);
|
||||
const [csv,setCsv]=useState(false);
|
||||
const [paymentControls,setPaymentControls]=useState(false);
|
||||
const pending=useRef<unknown>(null),sending=useRef(false);const dirty=selected.length>0||number!==""||document!==null||csv||paymentControls;const filtersChanged=!!loadedQuery&&JSON.stringify(query)!==JSON.stringify(loadedQuery);
|
||||
useEffect(()=>{setReview(false);},[selected,number,date,replaceExisting,loadedQuery]);
|
||||
useEffect(()=>{onDirtyChange(dirty);return()=>onDirtyChange(false);},[dirty,onDirtyChange]);
|
||||
useEffect(()=>{onBusyChange?.(busy||uncertain);return()=>onBusyChange?.(false);},[busy,uncertain,onBusyChange]);
|
||||
useEffect(()=>{request<typeof options>("/billing/options").then(setOptions).catch(e=>setError(e.message));},[refresh]);
|
||||
useEffect(()=>{if(!seed)return;let active=true;const q=initialQuery(seed);setQuery(q);setBusy(true);setError("");post<Result>("/billing/query",q).then(d=>{if(active){setData(d);setLoadedQuery(q);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[seed]);
|
||||
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 load(e?:FormEvent){e?.preventDefault();if(uncertain)return;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(sending.current||!data||!loadedQuery)return;if(!review&&!uncertain){setReview(true);return;}sending.current=true;setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),query:loadedQuery,revision:data.revision,orders:selected,number,date,replaceExisting};try{await post("/billing/assign",pending.current);pending.current=null;setUncertain(false);setNumber("");setSelected([]);setNotice("Rechnungsangaben zugeordnet.");setReview(false);try{const d=await post<Result>("/billing/query",loadedQuery);setData(d);setLoadedQuery({...loadedQuery});}catch(e){setData(null);setLoadedQuery(null);setError("Zuordnung gespeichert. Die Übersicht konnte nicht neu geladen werden: "+(e as Error).message);}}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{sending.current=false;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>}</>}
|
||||
const ids=data?[...new Set(data.rows.filter(r=>!r.incomplete&&(loadedQuery?.mode==="INVOICE"||!r.exported)&&(!r.invoicePresent||replaceExisting)).map(r=>r.id))]:[];
|
||||
return <section className="panel business-billing"><div className="section-toolbar"><div><h2>Abrechnung</h2><button className="button" disabled={busy||uncertain} onClick={()=>setPaymentControls(true)}>Zahlungskontrolle / Sammelzahlungen</button>{options?.canAssign&&<button className="button" disabled={busy||uncertain} onClick={()=>setCsv(true)}>Kunden-CSV · Zuschlagsaufteilung</button>}<p>Kundenleistungen, Kostenstellen und Kuriervergütungen nach Erledigungsdatum; Rechnungszuordnung nach Auftragsdatum.</p></div></div><ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}
|
||||
<form onSubmit={load}><fieldset className="business-fieldset" disabled={busy||uncertain}><div className="form-grid cols-3"><Field label={uiText("Bereich")}><select value={query.mode} onChange={e=>{setQuery(q=>({...q,mode:e.target.value}));setSelected([]);setReplaceExisting(false);}}><option value="CUSTOMER">Kundenabrechnung</option><option value="COURIER">Kurierabrechnung</option><option value="INVOICE">Rechnungszuordnung · exportierte Aufträge</option></select></Field><Field label={query.mode==="INVOICE"?"Auftragsdatum von":"Erledigt von"}><input type="date" required value={query.from} onChange={e=>setQuery(q=>({...q,from:e.target.value}))}/></Field><Field label={query.mode==="INVOICE"?"Auftragsdatum bis":"Erledigt bis"}><input type="date" required value={query.to} onChange={e=>setQuery(q=>({...q,to:e.target.value}))}/></Field><Field label={uiText("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={uiText("Kostenstelle")}><select disabled={!query.customer||query.mode==="COURIER"} value={query.center??""} onChange={e=>setQuery(q=>({...q,center:e.target.value?Number(e.target.value):null}))}><option value="">{uiText("Alle Kostenstellen")}</option>{centers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label={uiText("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><Field label="Fahrzeugtypen"><select multiple value={query.vehicleTypes.map(String)} onChange={e=>setQuery(q=>({...q,vehicleTypes:Array.from(e.target.selectedOptions,o=>Number(o.value))}))}>{options?.vehicleTypes?.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select><small>Keine Auswahl: alle Fahrzeugtypen</small></Field></div><button className="button primary">{busy?"Lädt …":uiText("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>{uiText("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>
|
||||
{options?.canAssign&&loadedQuery?.mode!=="COURIER"&&loadedQuery?.customer&&<section className="business-section"><h3>Auftragsbelege je Rechnungsnummer</h3><div className="admin-row-actions">{[...new Set(data.rows.map(r=>r.invoice).filter(Boolean))].map(invoice=><button key={invoice} className="button" disabled={busy||uncertain||filtersChanged} onClick={()=>setDocument({customer:loadedQuery.customer!,number:invoice})}>{invoice} · Belege ansehen</button>)}</div>{!data.rows.some(r=>r.invoice)&&<p>Für die Auswahl ist noch keine Rechnungsnummer hinterlegt.</p>}</section>}
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Auswahl</th><th>Auftrag / Termine</th><th>Kunde / Kostenstelle</th><th>Fahrer</th><th>{uiText("Rechnung")}</th><th>Netto</th><th>{uiText("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==="COURIER"||!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>Auftrag: {r.ordered?.replace("T"," ")}</small><small>Erledigt: {r.finished?.replace("T"," ")}</small>{r.incomplete&&<small>Nachbearbeitung erforderlich</small>}{r.storno>0&&<small>{r.storno>2?"Gegenbuchung":uiText("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.invoiceDate&&<small>{r.invoiceDate.replace("T"," ")}</small>}{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!=="COURIER"&&<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>{query.mode==="INVOICE"&&<label className="checkbox-label"><input type="checkbox" checked={replaceExisting} onChange={e=>{setReplaceExisting(e.target.checked);setSelected([]);pending.current=null;}}/>Vorhandene Rechnungsangaben korrigieren</label>}<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([]);}}>{uiText("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>{review&&<section aria-label="Rechnungszuordnung prüfen"><h4>Zuordnung prüfen</h4><p>Neue Rechnung: <strong>{number}</strong> · Datum: <strong>{date}</strong></p><ul>{data.rows.filter((r,i,all)=>selected.includes(r.id)&&all.findIndex(x=>x.id===r.id)===i).map(r=><li key={r.id}>Auftrag #{r.id}: {r.invoicePresent?`${r.invoice||"Leere Nummer"} · ${r.invoiceDate||"Ohne Datum"}`:"Ohne Zuordnung"} → {number} · {date}</li>)}</ul><p>Mit „Zuordnung speichern“ werden diese Rechnungsangaben übernommen.</p></section>}<button className="button primary" disabled={!selected.length||!query.customer}>{review?"Zuordnung speichern":"Zuordnung prüfen"}</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||uncertain} className="button" onClick={()=>void resume(p.token)}>Zuordnung fortsetzen · {p.token.slice(0,8)}</button><button disabled={busy||uncertain} className="button" onClick={()=>void resume(p.token,true)}>Vorgang beenden, gespeicherte Zuordnungen behalten</button></div>)}</section>}</>}
|
||||
{paymentControls&&<PaymentControls onClose={()=>setPaymentControls(false)}/>}
|
||||
{csv&&<InvoiceCsvExport customers={options?.customers??[]} customer={query.customer??0} onClose={()=>setCsv(false)}/>}
|
||||
{document&&<InvoiceDocuments {...document} onClose={()=>setDocument(null)}/>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,29 +1,91 @@
|
||||
import {VehicleRemoval} from './VehicleRemoval';
|
||||
import {VehicleGallery} from './VehicleGallery';
|
||||
import {VehicleActivationNotice,type VehicleActivation} from './VehicleActivationNotice';
|
||||
import {VehicleCopy} from './VehicleCopy';
|
||||
import {VehicleDriverAssignment} from './VehicleDriverAssignment';
|
||||
import {VehicleCapabilities} from './VehicleCapabilities';
|
||||
import {CustomerInvoiceAddress} from './CustomerInvoiceAddress';
|
||||
import {useMasterBankTools} from './MasterBankTools';
|
||||
import {changedBankError} from './masterBank.mjs';
|
||||
import {CustomerContacts} from './CustomerContacts';
|
||||
import {CustomerStammblatt} from './CustomerStammblatt';
|
||||
import {CustomerPhotoSettings} from "./CustomerPhotoSettings";
|
||||
import {CustomerReferenceField} from './CustomerReferenceField';
|
||||
import {CustomerPreviousNumber} from "./CustomerPreviousNumber";
|
||||
import {CustomerEntrySettings} from "./CustomerEntrySettings";
|
||||
import {CustomerSimilarity} from "./CustomerSimilarity";
|
||||
import {CustomerAddressPropagation} from "./CustomerAddressPropagation";
|
||||
import {MasterIdentifier} from "./MasterIdentifier";
|
||||
import {CourierDevicePhone} from './CourierDevicePhone';
|
||||
import {CourierAvailability} from './CourierAvailability';
|
||||
import {CourierDriverProfile} from './CourierDriverProfile';
|
||||
import {PersonalLinks} from './PersonalLinks';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {PortalEmployeesDialog} from './PortalEmployees';
|
||||
import {PortalAccountDialog} from './PortalAccounts';
|
||||
import {MasterDocumentsDialog} from './MasterDocuments';
|
||||
import {CustomerMailSettings,customerMailExtraLabels} from './CustomerMailSettings';
|
||||
import {CustomerRollcardMailSettings} from './CustomerRollcardMailSettings';
|
||||
import {VehicleCustomerRelations} from './VehicleCustomerRelations';
|
||||
import {MasterForms} from './MasterForms';
|
||||
import {CustomerCourierRelations} from './CustomerCourierRelations';
|
||||
import {CustomerServiceDocuments} from "./CustomerServiceDocuments";
|
||||
import {CustomerServiceSettings} from "./CustomerServiceSettings";
|
||||
import {CustomerServices} from "./CustomerServices";
|
||||
import {MasterAddressLookup} from "./MasterAddressLookup";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } 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 type EditorData={canContacts?:boolean;associatedVehicleCourier?:boolean;vehicleStock?:{enabled?:boolean;existing?:boolean;label?:string};vehicleActivation?:VehicleActivation;pendingPhotoSettings?:number;photoCopy?:{status:string;message:string};copySource?:{id:number;revision:string;photoRevision?:string};copyLabel?:string;copyPayerRemoved?:boolean;copyDetails?:string[];disabledFields?:string[];customerType?:{hasOrders:boolean;storedType:number;customerSince:string;pendingReports:number};creationDefaults?:{automaticDispatch:boolean;cargoRequired:boolean;mailFields:string;revision:string};companyRules?:{internalBookingTerms:string[];automaticSimilarity?:boolean};identifier?:{key:string;canSuggest:boolean;readOnly:boolean};fieldHints?:Record<string,string>;canManagePortalEmployees?:boolean;canDocuments?:boolean;canManageVehicleRelations?:boolean;canManageServices?:boolean;canManageRelations?: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=>{
|
||||
export function BusinessFields({fields,values,options={},onChange,addressKind,disabled=false,onAddress,fieldHints={},fieldActions={},onFieldBlur,disabledFields=[]}:{disabledFields?:string[];onFieldBlur?:(key:string)=>void;fieldHints?:Record<string,string>;fieldActions?:Record<string,ReactNode>;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}){
|
||||
const uiText=useUiText();
|
||||
return <>{[...new Set(fields.map(f=>f.group))].map(group=><section className="business-section" key={group}><h3>{uiText(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>;
|
||||
if(f.type==="payerReference"||f.type==="customerReference")return <CustomerReferenceField key={f.key} kind={f.type==="payerReference"?"payer":"parent"} label={f.label} value={Number(value)} disabled={disabled||disabledFields.includes(f.key)} onChange={v=>onChange(f.key,v)}/>;
|
||||
return f.type==="check"?<label key={f.key} className="checkbox-label"><input type="checkbox" disabled={disabledFields.includes(f.key)} checked={Number(value)===1} onChange={e=>onChange(f.key,e.target.checked?1:0)}/>{uiText(f.label)}</label>:<Field key={f.key} label={uiText(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 disabled={disabledFields.includes(f.key)} 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 onBlur={()=>onFieldBlur?.(f.key)} readOnly={f.type==="readonly"} value={f.type==="datetime-local"?String(value).replace(" ","T").slice(0,16):value} type={f.type==="date"||f.type==="month"||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)}/>}{fieldHints[f.key]&&<small className="form-hint">{fieldHints[f.key]}</small>}{fieldActions[f.key]}</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 uiText=useUiText("admin/customer_special.php");
|
||||
|
||||
const [vehicleCopyOpen,setVehicleCopyOpen]=useState(false),[vehicleGalleryOpen,setVehicleGalleryOpen]=useState(false),[vehicleRemovalOpen,setVehicleRemovalOpen]=useState(false);
|
||||
const [vehicleDriverOpen,setVehicleDriverOpen]=useState(false);
|
||||
const [vehicleCapabilitiesOpen,setVehicleCapabilitiesOpen]=useState(false);
|
||||
const [driverProfileOpen,setDriverProfileOpen]=useState(false),[devicePhoneOpen,setDevicePhoneOpen]=useState(false);
|
||||
const [similarityOpen,setSimilarityOpen]=useState(false);const similarityValue=useRef('');
|
||||
const [previousNumberOpen,setPreviousNumberOpen]=useState(false);
|
||||
const [entrySettingsOpen,setEntrySettingsOpen]=useState(false);
|
||||
const [contactsOpen,setContactsOpen]=useState(false);
|
||||
const [stammblattOpen,setStammblattOpen]=useState(false);
|
||||
const [photoSettingsOpen,setPhotoSettingsOpen]=useState(false);
|
||||
const [propagationOpen,setPropagationOpen]=useState(false);
|
||||
const [copyLoading,setCopyLoading]=useState(false);
|
||||
const [documentsOpen,setDocumentsOpen]=useState(false);
|
||||
const [portalAccessOpen,setPortalAccessOpen]=useState(false);
|
||||
const [portalEmployeesOpen,setPortalEmployeesOpen]=useState(false);
|
||||
const [vehicleRelationsOpen,setVehicleRelationsOpen]=useState(false),[rollcardMailOpen,setRollcardMailOpen]=useState(false),[customerMailOpen,setCustomerMailOpen]=useState(false);
|
||||
const [relationsOpen,setRelationsOpen]=useState(false),[masterFormsOpen,setMasterFormsOpen]=useState(false);
|
||||
const [servicesOpen,setServicesOpen]=useState(false),[serviceSettingsOpen,setServiceSettingsOpen]=useState(false),[serviceDocumentsOpen,setServiceDocumentsOpen]=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);}}
|
||||
const [customerMailFields,setCustomerMailFields]=useState("0000000000");
|
||||
const [discard,dialog]=useDiscardConfirmation();const pending=useRef<{requestId:string;revision:string;values:Values;creationRevision?:string;customerMailFields?:string;copySource?:{id:number;revision:string;photoRevision?:string}}|null>(null);
|
||||
const bankTools=useMasterBankTools(kind==='customers',values,busy||uncertain||copyLoading,iban=>{pending.current=null;setValues(v=>({...v,cmp_iban:iban}));});
|
||||
const dirty=!!data&&(!!data.copySource||JSON.stringify(values)!==JSON.stringify(data.values)||(!!data.creationDefaults&&customerMailFields!==data.creationDefaults.mailFields));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);setCustomerMailFields(d.creationDefaults?.mailFields??"0000000000");similarityValue.current=String(d.values.cmp_comp??'').trim();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);}}/>}</>;
|
||||
async function copyCustomer(){if(!data||dirty||busy||uncertain)return;setCopyLoading(true);setError("");try{const d=await request<EditorData>(`/master-data/customers/${data.id}/copy`);setData(d);setValues(d.values);setCustomerMailFields(d.creationDefaults?.mailFields??"0000000000");pending.current=null;setNotice("");similarityValue.current=String(d.values.cmp_comp??"").trim();}catch(e){setError((e as Error).message);}finally{setCopyLoading(false);}}
|
||||
async function close(){if(!contactsOpen&&!vehicleCopyOpen&&!vehicleDriverOpen&&!vehicleCapabilitiesOpen&&!stammblattOpen&&!photoSettingsOpen&&!previousNumberOpen&&!entrySettingsOpen&&!similarityOpen&&!propagationOpen&&!driverProfileOpen&&!devicePhoneOpen&&!customerMailOpen&&!portalEmployeesOpen&&!portalAccessOpen&&!documentsOpen&&!rollcardMailOpen&&!vehicleRelationsOpen&&!relationsOpen&&!masterFormsOpen&&!servicesOpen&&!serviceSettingsOpen&&!serviceDocumentsOpen&&!child&&!copyLoading&&!busy&&!uncertain&&(!dirty||await discard("Ungespeicherte Stammdatenänderungen verwerfen?")))onClose();}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||copyLoading)return;const bankError=kind==='customers'?changedBankError(values,data.id===0?{}:data.values):'';if(bankError){setError(bankError);return;}setBusy(true);setError("");pending.current??={requestId:crypto.randomUUID(),revision:data.revision,values,...(data.creationDefaults?{creationRevision:data.creationDefaults.revision,customerMailFields}:{}),...(data.copySource?{copySource:data.copySource}:{})};try{const d=await put<EditorData>(`/master-data/${kind}/${data.id}?parent=${parent}`,pending.current);setData(d);setValues(d.values);setCustomerMailFields(d.creationDefaults?.mailFields??"0000000000");setNotice("Stammdaten gespeichert."+(d.photoCopy?" "+d.photoCopy.message:""));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.copySource&&<section className="alert info" aria-label="Kundenkopie"><h3>Kopie von {data.copyLabel}</h3>{data.copyDetails?.map(text=><p key={text}>{text}</p>)}{data.copyPayerRemoved&&<p>Der Drittzahler wurde entsprechend der Niederlassungsvorgabe entfernt.</p>}<p>Bitte eine neue Kundennummer wählen und die Angaben prüfen. Erst Speichern legt den neuen Kunden an.</p></section>}{!!data.pendingPhotoSettings&&<p className="alert info" role="status">Eine Foto-Übertragung ist noch offen. Bitte unter Foto-Workflow-Sets den Status prüfen.</p>}{copyLoading&&<p role="status">Kopie wird vorbereitet …</p>}{data.id>0&&["customers","couriers"].includes(kind)&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setPortalAccessOpen(true)}>Portal-Hauptzugang</button>{kind==="customers"&&<button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setStammblattOpen(true)}>Stammblatt als PDF</button>}{kind==="customers"&&data.canContacts&&<button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setContactsOpen(true)}>Kontakte</button>}{kind==="customers"&&data.canManageServices&&<button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>void copyCustomer()}>Als neuen Kunden kopieren</button>}{kind==="customers"&&<button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setPreviousNumberOpen(true)}>Alte Kundennummer / Umfirmierung</button>}{kind==="customers"&&data.canManagePortalEmployees&&<> <button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setPortalEmployeesOpen(true)}>Kundenmitarbeiter / Portalrechte</button></>}</section>}{data.id>0&&kind==="couriers"&&<CourierAvailability courier={data.id} refreshKey={data.revision}/>}{data.id>0&&kind==="couriers"&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setDriverProfileOpen(true)}>Fahrerprofil / App-Zugang</button> <button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setDevicePhoneOpen(true)}>Gerätezuordnung / VPA-PDA</button></section>}{data.id>0&&data.canDocuments&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setDocumentsOpen(true)}>Dokumente / Dateien</button></section>}{data.id>0&&['customers','couriers','vehicles'].includes(kind)&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setMasterFormsOpen(true)}>Zusatzformulare / 2. Seite</button></section>}{data.associatedVehicleCourier&&<p className="alert info">Interner Fahrzeugkurier aktiv: Beim Speichern wird die technische Zuordnung geprüft und nachgeführt. Neue Fahrzeugnummern sind wegen der Kennung INT_ + SID auf sechs Zeichen begrenzt. Neu angelegte technische Konten erhalten keinen Portal- oder App-Zugang.</p>}{data.vehicleStock?.enabled&&<p className="alert info">{data.vehicleStock.existing?`Fahrzeuglager: ${data.vehicleStock.label||"vorhanden"}. Bestand und Lagerort bleiben erhalten.`:"Beim Speichern wird gemäß der Systemvorgabe ein eigener Fahrzeuglagerort angelegt."}</p>}{data.vehicleActivation&&<VehicleActivationNotice value={data.vehicleActivation} sid={String(values.crvh_sid??'')}/>}{data.id>0&&kind==="vehicles"&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleGalleryOpen(true)}>Fahrzeugbilder</button> <button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleCapabilitiesOpen(true)}>Filter und Leistungen</button> <button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleDriverOpen(true)}>Fahrerzuordnung</button> <button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleCopyOpen(true)}>Fahrzeug kopieren</button> <button type="button" className="button danger" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleRemovalOpen(true)}>Fahrzeug entfernen</button></section>}{data.id>0&&data.canManageVehicleRelations&&<section className="business-section"><button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setVehicleRelationsOpen(true)}>KundenzuordnungeLine truncated
|
||||
{data.customerType.hasOrders?<p>Es bestehen Zahleraufträge. Der Kundentyp ist deshalb auf Kunde festgelegt.</p>:<p>Bei Interessenten, Potentialkunden und Wunschkunden ist die Sendungsverfolgung ausgeschaltet.</p>}
|
||||
{data.customerType.customerSince&&<p>Kunde seit: {data.customerType.customerSince}</p>}
|
||||
{(data.id===0||Number(values.cmp_type)!==data.customerType.storedType)&&<p>Beim Speichern werden der Kundentyp und ein Vertriebsbericht protokolliert.{Number(values.cmp_type)===0?' Als Datum „Kunde seit“ wird heute gesetzt.':''}</p>}
|
||||
{data.customerType.pendingReports>0&&<p role="status">{data.customerType.pendingReports} Vertriebsbericht(e) warten noch auf Übertragung. Der gespeicherte Kundentyp ist wirksam; die Übertragung wird automatisch erneut versucht.</p>}
|
||||
</section>}<>{data.creationDefaults&&<section className="business-section" aria-label="Zusätzliche E-Mail-Vorgaben für neue Kunden"><h3>Zusätzliche E-Mail-Felder</h3><p className="form-hint">Die Vorauswahl stammt aus der Niederlassung. Sie können sie für diesen Kunden vor dem Speichern ändern.</p><div className="form-grid cols-2">{Object.entries(customerMailExtraLabels).map(([bit,label])=><label className="admin-check" key={bit}><input type="checkbox" checked={customerMailFields[Number(bit)]==='1'} onChange={e=>{pending.current=null;setCustomerMailFields(v=>v.slice(0,Number(bit))+(e.target.checked?'1':'0')+v.slice(Number(bit)+1));}}/>{label}</label>)}</div></section>}</><BusinessFields disabledFields={[...(data.disabledFields??[]),...(kind==='customers'?[...(data.customerType?.hasOrders?['cmp_type']:[]),...(Number(values.cmp_type)!==0?['cs_tracking']:[])]:[])]} fields={data.fields} values={values} options={data.options} fieldHints={data.fieldHints} onFieldBlur={key=>{bankTools.onBlur(key);const value=String(values.cmp_comp??'').trim();if(key==='cmp_comp'&&kind==='customers'&&data.companyRules?.automaticSimilarity&&value&&value!==similarityValue.current){similarityValue.current=value;setSimilarityOpen(true);}}} fieldActions={{...bankTools.fieldActions,...(data.identifier?.canSuggest?{[data.identifier.key]:<MasterIdentifier kind={kind} value={String(values[data.identifier.key]??'')} disabled={busy||uncertain||copyLoading} onSelect={(key,value)=>{pending.current=null;setValues(v=>({...v,[key]:value}));}}/>}:{}),...(kind==='customers'?{cmp_comp:<>{data.companyRules?.internalBookingTerms.some(term=>String(values.cmp_comp??'').toLowerCase().includes(term))&&<p className="form-hint" role="status">Falls dieser Kunde ein interner Verrechnungskunde ist, bitte den Steuersatz prüfen.</p>}<button type="button" className="button" disabled={busy||uncertain||!String(values.cmp_comp??'').trim()} onClick={()=>{similarityValue.current=String(values.cmp_comp??'').trim();setSimilarityOpen(true);}}>Ähnliche Kunden suchen</button></>}:{} )}} addressKind={kind} disabled={busy||uncertain||copyLoading} onAddress={patch=>{pending.current=null;setValues(v=>({...v,...patch}));}} onChange={(key,value)=>{pending.current=null;setValues(v=>({...v,[key]:value,...(kind==='customers'&&key==='cmp_type'&&Number(value)!==0?{cs_tracking:0}:{})}));}}/></fieldset>{kind==="customers"&&data.id>0&&<CustomerInvoiceAddress customer={data.id} revision={data.revision} disabled={dirty||busy||uncertain||copyLoading} onOpen={center=>setChild({kind:"centers",id:center,parent:data.id})}/>}{data.id>0&&data.children&&<section className="business-section"><h3>{kind==="customers"?uiText("Kostenstellen"):uiText("Fahrzeuge")}</h3>{kind==="customers"&&<button type="button" className="button" disabled={dirty||busy||uncertain||copyLoading} onClick={()=>setPropagationOpen(true)}>Kundenangaben in Kostenstellen übernehmen</button>}<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||copyLoading} 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||copyLoading} 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||copyLoading} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button primary" disabled={busy||copyLoading||!data||(!dirty&&data.id>0)}>{busy?"Speichert …":uiText("Speichern")}</button></div></form></Modal>{dialog}{similarityOpen&&data&&<CustomerSimilarity initial={String(values.cmp_comp??'')} onClose={()=>setSimilarityOpen(false)} onSaved={onSaved}/>}{propagationOpen&&data&&<CustomerAddressPropagation customer={data.id} onClose={()=>{setPropagationOpen(false);void load(data.id);onSaved();}}/>}{devicePhoneOpen&&data&&<CourierDevicePhone courier={data.id} onClose={()=>{setDevicePhoneOpen(false);void load(data.id);}}/>}{vehicleRemovalOpen&&data&&<VehicleRemoval vehicle={data.id} onClose={()=>setVehicleRemovalOpen(false)} onRemoved={()=>{setVehicleRemovalOpen(false);onSaved();onClose();}}/>}{vehicleGalleryOpen&&data&&<VehicleGallery vehicle={data.id} onClose={()=>setVehicleGalleryOpen(false)}/>}{vehicleCopyOpen&&data&&<VehicleCopy vehicle={data.id} onClose={()=>setVehicleCopyOpen(false)} onSaved={onSaved}/> }{vehicleDriverOpen&&data&&<VehicleDriverAssignment vehicle={data.id} onClose={()=>setVehicleDriverOpen(false)} onTransferred={()=>{setVehicleDriverOpen(false);onSaved();onClose();}}/>}{vehicleCapabilitiesOpen&&data&&<VehicleCapabilities vehicle={data.id} onClose={()=>{setVehLine truncated
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError} from './api';
|
||||
import type {CashResult} from './Cashflow';
|
||||
type Profile={id:number;label:string};
|
||||
type Preview={revision:string;bytes:number;content:string;truncated:boolean;filename:string;canStore:boolean;notice:string};
|
||||
type Operation={requestId:string;filename:string;customer:number;collections:number};
|
||||
type Mutation={path:string;body:unknown};
|
||||
const base='/cashflow/export-runs';
|
||||
export function CashExportExecution({data,paymentFilter,profiles,onBusy}:{data:CashResult;paymentFilter:number;profiles:Profile[];onBusy:(v:boolean)=>void}){
|
||||
const [profile,setProfile]=useState(903),[filename,setFilename]=useState(`Einnahmen-${data.query.to}.ums`),[preview,setPreview]=useState<Preview|null>(null),[operations,setOperations]=useState<Operation[]>([]),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[confirmed,setConfirmed]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<Mutation|null>(null),[confirm,dialog]=useDiscardConfirmation({title:'Einnahmenexport verwerfen',confirm:'Vorgang verwerfen',cancel:'Zurück'});
|
||||
const selection={query:data.query,paymentFilter},input={selection,profile,filename};
|
||||
useEffect(()=>{if(profiles.length&&!profiles.some(p=>p.id===profile))setProfile(profiles[0].id);},[profiles,profile]);
|
||||
useEffect(()=>{const c=new AbortController();request<{operations:Operation[]}>(`${base}?headquarters=${data.query.headquarters}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setOperations(v.operations);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[data.query.headquarters]);
|
||||
async function refresh(){setOperations((await request<{operations:Operation[]}>(`${base}?headquarters=${data.query.headquarters}`)).operations);}
|
||||
function edit(){setPreview(null);setConfirmed(false);setNotice('');}
|
||||
async function prepare(){if(sending.current||uncertain)return;sending.current=true;setBusy(true);onBusy(true);setError('');edit();try{setPreview(await post<Preview>(base+'/preview',input));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);onBusy(false);}}
|
||||
async function perform(m:Mutation){if(sending.current)return;sending.current=true;pending.current=m;setBusy(true);onBusy(true);setError('');setNotice('');let unknown=false;try{const result=await post<{completed?:boolean;cancelled?:boolean;file?:number;replayed?:boolean}>(m.path,m.body);pending.current=null;setUncertain(false);setPreview(null);setConfirmed(false);setNotice(result.completed?`Exportdatei #${result.file} gespeichert. Die Zahlungen sind als exportiert gekennzeichnet. Die Datei ist im Datentransfer verfügbar.`:'Offenen Export verworfen. Neu angelegte Sammlungen wurden aufgelöst.');await refresh();}catch(e){setError((e as Error).message);unknown=pending.current!==null&&(!(e instanceof ApiError)||e.status===0||e.status>=500);setUncertain(unknown);if(!unknown){pending.current=null;try{await refresh();}catch{/* The original error remains visible. */}}}finally{sending.current=false;setBusy(false);onBusy(unknown);}}
|
||||
async function discard(o:Operation){if(await confirm(`Einnahmenexport „${o.filename}“ verwerfen? Seine neu angelegten Sammlungen werden aufgelöst; bereits vorher vorhandene Sammlungen bleiben erhalten.`))void perform({path:`${base}/${o.requestId}/cancel?headquarters=${data.query.headquarters}`,body:{}});}
|
||||
return <section><h3>Exportdatei erstellen</h3>{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{uncertain&&<p className="alert info">Die Antwort fehlt. Bitte denselben Vorgang erneut prüfen; es wird keine zweite Sammlung angelegt.</p>}
|
||||
<fieldset className="business-fieldset" disabled={busy||uncertain}><div className="form-grid cols-2"><Field label="Einnahmenexportprofil"><select disabled={!profiles.length} value={profile} onChange={e=>{setProfile(Number(e.target.value));edit();}}>{!profiles.length&&<option value={903}>Keine Felddefinition verfügbar</option>}{profiles.map(p=><option key={p.id} value={p.id}>{p.label}</option>)}</select></Field><Field label="Exportdateiname"><input maxLength={100} value={filename} onChange={e=>{setFilename(e.target.value);edit();}}/></Field></div><button className="button" disabled={!profiles.length} onClick={()=>void prepare()}>Dateivorschau prüfen</button>
|
||||
{preview&&<><p>{preview.notice}</p><p>{preview.filename} · {preview.bytes.toLocaleString('de-DE')} Bytes · Windows-1252</p><pre style={{maxHeight:240,overflow:'auto'}} aria-label="Einnahmen-Dateivorschau">{preview.content}</pre>{preview.truncated&&<p>Die angezeigte Vorschau ist gekürzt.</p>}<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Ich bestätige Dateierstellung und Exportkennzeichnung der ausgewählten Zahlungen.</label><button className="button primary" disabled={!confirmed||!preview.canStore} onClick={()=>void perform({path:base,body:{requestId:crypto.randomUUID(),input,revision:preview.revision,confirmed:true}})}>Einnahmenexport ausführen</button></>}
|
||||
{operations.length>0&&<><h4>Offene Einnahmenexporte</h4>{operations.map(o=><div className="admin-toolbar" key={o.requestId}><span>{o.filename} · {o.collections} Sammlungen</span><button className="button" onClick={()=>void perform({path:`${base}/${o.requestId}/resume?headquarters=${data.query.headquarters}`,body:{}})}>Export fortsetzen</button><button className="button" onClick={()=>void discard(o)}>Export verwerfen</button></div>)}</>}
|
||||
</fieldset>{uncertain&&<button className="button primary" disabled={busy} onClick={()=>pending.current&&void perform(pending.current)}>Ergebnis erneut prüfen</button>}{dialog}</section>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {CashExportExecution} from './CashExportExecution';
|
||||
import {useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {post} from './api';
|
||||
import {cashMoney,type CashResult} from './Cashflow';
|
||||
type Group={date:string;counter:number;mode:number;installation:number;invoice:string;payments:number[];net:number};
|
||||
type Preview={groups:Group[];existing:{id:number;date:string;mode:number;invoice:string;payments:number[];net:number}[];modes:number[];net:number;notice:string;special:boolean;formatAvailable:boolean;profiles:{id:number;label:string}[]};
|
||||
export function CashExportPlan({data,onClose}:{data:CashResult;onClose:()=>void}){
|
||||
const [filter,setFilter]=useState(1),[preview,setPreview]=useState<Preview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
async function load(){setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>('/cashflow/export-plan',{query:data.query,paymentFilter:filter}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const label=(mode:number)=>data.modes.find(m=>m.id===mode)?.label||`Zahlart ${mode}`;
|
||||
return <Modal title="Einnahmenexport vorbereiten" wide onClose={()=>{if(!busy)onClose();}}><div className="modal-body admin-dialog-body"><p>{data.customer} · Buchungszeitraum {data.query.from} bis {data.query.to}</p><p>Die Vorschau zeigt die nach den Exportvorgaben zusammengefassten Zahlungen. Es werden noch keine Sammelnummern vergeben oder Zahlungen als exportiert markiert.</p>{error&&<p role="alert" className="alert error">{error}</p>}<form onSubmit={e=>{e.preventDefault();void load();}}><fieldset disabled={busy} className="business-fieldset"><Field label="Konfigurierte Zahlartengruppe"><input type="number" min={1} max={99} required value={filter} onChange={e=>{setFilter(Number(e.target.value));setPreview(null);}}/></Field><p className="form-hint">Gruppe 1 entspricht dem Einnahmenexport aus der PHP-Kassenübersicht. Weitere Gruppen verwenden die hinterlegten Kundenvorgaben.</p><button className="button primary">{busy?'Wird geprüft …':'Zahlungssammlungen prüfen'}</button></fieldset></form>{preview&&<><p role="status" className="alert info">{preview.notice}</p><p>Ausgewählte Zahlarten: {preview.modes.map(label).join(', ')}</p><div className="business-summary"><span>Summe netto <strong>{cashMoney(preview.net)}</strong></span><span>{preview.groups.length} neue Sammlungen · {preview.existing.length} bereits vorbereitet</span></div><h3>Neue Zahlungssammlungen</h3><div className="table-scroll"><table className="data-table"><thead><tr><th>Buchungsdatum</th><th>Bereich</th><th>Zahlart</th><th>Zahlungen</th><th>Netto</th><th>Rechnungstext</th></tr></thead><tbody>{preview.groups.map((g,i)=><tr key={i}><td>{g.date}</td><td>{g.counter===-1?'Kasse und Monteur':g.counter===1?'Kasse':'Monteur'}{g.installation>=0&&(g.installation===1?' · Montage':' · Lieferung')}</td><td>{label(g.mode)}</td><td>{g.payments.length}</td><td>{cashMoney(g.net)}</td><td>{g.invoice}</td></tr>)}</tbody></table></div>{!preview.groups.length&&<p>Keine neuen Zahlungen für diese Auswahl.</p>}<h3>Bereits vorbereitete Sammlungen</h3><p>Offene Sammlungen gehören auch dann zum Export, wenn ihr Buchungsdatum außerhalb des oben gewählten Zeitraums liegt.</p><div className="table-scroll"><table className="data-table"><thead><tr><th>Sammlung</th><th>Buchungsdatum</th><th>Zahlart</th><th>Zahlungen</th><th>Netto</th></tr></thead><tbody>{preview.existing.map(g=><tr key={g.id}><td>#{g.id}</td><td>{g.date}</td><td>{label(g.mode)}</td><td>{g.payments.length}</td><td>{cashMoney(g.net)}</td></tr>)}</tbody></table></div>{!preview.existing.length&&<p>Keine bereits vorbereiteten Sammlungen.</p>}</>}<CashExportExecution key={filter} data={data} paymentFilter={filter} profiles={preview?.profiles??[]} onBusy={setBusy}/></div><div className="modal-footer"><button className="button" disabled={busy} onClick={onClose}>Schließen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {downloadBusinessFile,post,request} from './api';
|
||||
import {CashExportPlan} from './CashExportPlan';
|
||||
import {CashflowMail} from './CashflowMail';
|
||||
import {CashflowEdit} from './CashflowEdit';
|
||||
import {OrderWorkbench} from './OrderWorkbench';
|
||||
export type CashQuery={view:number;headquarters:number;customer:number;from:string;to:string;fromTime:string|null;toTime:string|null;counter:number;service:number;mode:number|null;calculator:boolean;articles:boolean};
|
||||
export type CashPayment={id:number;mode:number;net:number;gross:number;counter:boolean;collection:number;booked:string;exported:string};
|
||||
export type CashRow={job:number;displayNumber:string;parent:number;installation:boolean;label:string;paid:number;due:number;remaining:number;booked:string;reference:string;payments:CashPayment[];allPayments?:CashPayment[];collections:number[];calculation:Record<string,string|number>[];articles:Record<string,string|number>[]};
|
||||
export type CashResult={query:CashQuery;title:string;customer:string;rows:CashRow[];dayTotals:{label:string;amount:number}[];paid:number;due:number;modes:{id:number;label:string}[];revision:string;canEdit:boolean};
|
||||
type Options={headquarters:{id:number;label:string}[];currentHeadquarters:number;customers:{id:number;label:string}[];defaultCustomer:number;canChooseCustomer:boolean;canChoosePeriod:boolean;canOpenOrders:boolean;canExport:boolean;views:{id:number;label:string}[]};
|
||||
const today=()=>new Date().toLocaleDateString('sv-SE');export const cashMoney=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
export function CashflowLauncher({onDirtyChange}:{onDirtyChange:(dirty:boolean)=>void}){const [open,setOpen]=useState(false);useEffect(()=>{onDirtyChange(open);return()=>onDirtyChange(false);},[open,onDirtyChange]);return <><section className="panel"><div className="section-toolbar"><div><h2>Kasse / Inkasso</h2><p>Einnahmen prüfen, Zahlungen zuordnen und Kassenübersichten herunterladen.</p></div><button className="button primary" onClick={()=>setOpen(true)}>Kasse öffnen</button></div></section>{open&&<Cashflow onClose={()=>setOpen(false)}/>}</>;}
|
||||
export function Cashflow({onClose}:{onClose:()=>void}){
|
||||
const [query,setQuery]=useState<CashQuery>({view:0,headquarters:0,customer:0,from:today(),to:today(),fromTime:null,toTime:null,counter:1,service:0,mode:null,calculator:false,articles:false}),[options,setOptions]=useState<Options|null>(null),[data,setData]=useState<CashResult|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[edit,setEdit]=useState<CashRow|null>(null),[order,setOrder]=useState<number|null>(null),[mail,setMail]=useState(false),[exportPlan,setExportPlan]=useState(false);
|
||||
useEffect(()=>{const c=new AbortController();request<Options>('/cashflow/options'+(query.headquarters?`?headquarters=${query.headquarters}`:''),{signal:c.signal}).then(o=>{if(c.signal.aborted)return;setOptions(o);setQuery(q=>({...q,headquarters:o.currentHeadquarters,customer:o.customers.some(v=>v.id===q.customer)?q.customer:o.defaultCustomer}));}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[query.headquarters]);
|
||||
const changed=!!data&&JSON.stringify(data.query)!==JSON.stringify(query),blocked=busy||edit!==null||order!==null||mail||exportPlan;
|
||||
async function load(){if(blocked)return;setError('');setBusy(true);try{const d=await post<CashResult>('/cashflow/query',query);setData(d);setQuery(d.query);}catch(e){setData(null);setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function download(pdf:boolean){if(!data||changed||blocked)return;setBusy(true);setError('');try{await downloadBusinessFile(pdf?'/cashflow/statement.pdf':'/cashflow/export.csv',{query:data.query,revision:data.revision},pdf?'Kasse.pdf':'Kasse.csv');}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
return <><Modal title="Kasse / Inkasso" wide onClose={()=>{if(!blocked)onClose();}}><div className="modal-body admin-dialog-body">{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
<form onSubmit={e=>{e.preventDefault();void load();}}><fieldset className="business-fieldset" disabled={blocked||!options}><div className="form-grid cols-3">
|
||||
<Field label="Kassenansicht"><select value={query.view} onChange={e=>setQuery(q=>({...q,view:Number(e.target.value),counter:1,service:0,mode:null}))}>{options?.views.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
|
||||
<Field label="Niederlassung"><select value={query.headquarters} onChange={e=>{setData(null);setOptions(null);setQuery(q=>({...q,headquarters:Number(e.target.value),customer:0}));}}>{options?.headquarters.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
|
||||
<Field label="Zugehöriger Kunde"><select required={query.view===0} disabled={!options?.canChooseCustomer} value={query.customer||''} onChange={e=>setQuery(q=>({...q,customer:Number(e.target.value)}))}><option value="">{query.view===0?'Bitte wählen':'Alle Kunden'}</option>{options?.customers.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
|
||||
<Field label="Von"><input required type="date" value={query.from} onChange={e=>setQuery(q=>({...q,from:e.target.value,to:options?.canChoosePeriod?q.to:e.target.value}))}/></Field><Field label="Bis"><input required type="date" disabled={!options?.canChoosePeriod} value={query.to} onChange={e=>setQuery(q=>({...q,to:e.target.value}))}/></Field>
|
||||
{query.view!==0&&<><Field label="Einnahmen"><select value={query.counter} onChange={e=>setQuery(q=>({...q,counter:Number(e.target.value)}))}><option value={1}>Counter / Kasse</option><option value={2}>Monteur-Inkasso</option></select></Field><Field label="Auftragstyp"><select value={query.service} onChange={e=>setQuery(q=>({...q,service:Number(e.target.value)}))}><option value={0}>Lieferung und Montage</option><option value={1}>Lieferung</option><option value={2}>Montage</option></select></Field></>}
|
||||
<Field label="Uhrzeit von (optional)"><input type="time" value={query.fromTime?.slice(0,5)||''} onChange={e=>setQuery(q=>({...q,fromTime:e.target.value||null}))}/></Field><Field label="Uhrzeit bis (optional)"><input type="time" value={query.toTime?.slice(0,5)||''} onChange={e=>setQuery(q=>({...q,toTime:e.target.value||null}))}/></Field>
|
||||
<label className="checkbox-label"><input type="checkbox" checked={query.calculator} onChange={e=>setQuery(q=>({...q,calculator:e.target.checked}))}/>Auftragsposten anzeigen</label><label className="checkbox-label"><input type="checkbox" checked={query.articles} onChange={e=>setQuery(q=>({...q,articles:e.target.checked}))}/>Artikel anzeigen</label>
|
||||
</div><p className="form-hint">{query.view===0?'Die Kassenübersicht wählt Aufträge nach Erstellungsdatum und ergänzt zugehörige Montageaufträge.':'Die Einnahmenübersichten verwenden das Buchungsdatum der Zahlung.'} Bruttobeträge entsprechen der PHP-Kassenregel mit 19 %.</p><button className="button primary">{busy?'Lädt …':'Anzeigen'}</button></fieldset></form>
|
||||
{changed&&<p className="alert info">Filter geändert. „Anzeigen“ lädt die neue Auswahl.</p>}
|
||||
{data&&<><h3>{data.title} · {data.customer}</h3><div className="business-summary"><span>Einnahmen brutto <strong>{cashMoney(data.paid)}</strong></span>{data.query.view!==3&&<span>Soll brutto <strong>{cashMoney(data.due)}</strong></span>}</div><div className="admin-row-actions"><button className="button" disabled={blocked||changed} onClick={()=>void download(false)}>CSV herunterladen</button><button className="button" disabled={blocked||changed} onClick={()=>void download(true)}>PDF herunterladen</button><button className="button" disabled={blocked||changed||!data.rows.length} onClick={()=>setMail(true)}>PDF per E-Mail</button>{options?.canExport&&<button className="button" disabled={blocked||changed||!data.query.customer} onClick={()=>setExportPlan(true)}>Einnahmenexport vorbereiten</button>}</div>
|
||||
<div className="table-scroll"><table className="data-table"><thead><tr><th>Auftrag / Bereich</th><th>Zahlart</th><th>Einnahmen brutto</th>{data.query.view!==3&&<th>Soll brutto</th>}<th>Buchung / Sammlung</th>{data.query.view===0&&<th>Offener Betrag / Aktionen</th>}{(data.query.articles||data.query.calculator)&&<th>Positionen</th>}</tr></thead><tbody>{data.rows.map((r,i)=><tr key={`${r.job}-${i}`}><td>{r.job>0?<>{options?.canOpenOrders?<button className="button" disabled={blocked} onClick={()=>setOrder(r.job)}>#{r.displayNumber}</button>:<strong>#{r.displayNumber}</strong>}<small style={{display:'block'}}>{r.installation?'Montage':'Lieferung'}{r.parent?` · Hauptauftrag #${r.parent}`:''}</small>{r.reference&&<small>Referenz: {r.reference}</small>}</>:r.label}</td><td>{r.label}</td><td>{cashMoney(r.paid)}</td>{data.query.view!==3&&<td>{cashMoney(r.due)}</td>}<td>{r.booked||'–'}{r.collections.length>0&&<small>Sammlung: {r.collections.join(', ')}</small>}</td>{data.query.view===0&&<td><span style={{display:'block'}}>{cashMoney(r.remaining)}</span>{data.canEdit&&<button className="button" disabled={blocked||changed} onClick={()=>setEdit(r)}>Zahlungen bearbeiten</button>}</td>}{(data.query.articles||data.query.calculator)&&<td>{r.calculation.map((c,i)=><small key={`c${i}`}>{c.srv_name} / {c.srvt_name}: {c.jbc_amount} × {cashMoney(Number(c.jbc_price))} = {cashMoney(Number(c.jbc_totalprice))} netto</small>)}{r.articles.map((a,i)=><small key={`a${i}`}>{a.trat_quantity} × {a.trat_name} {a.trat_serialno} {a.trat_remark}</small>)}</td>}</tr>)}</tbody></table>{!data.rows.length&&<p>Keine Kassenpositionen für diese Auswahl.</p>}</div>
|
||||
{!!data.dayTotals.length&&<section className="business-section"><h3>Kasseneinnahmen nach Buchungszeitraum</h3><p>Diese Summe kann von den oben nach Erstellungsdatum ausgewählten Aufträgen abweichen.</p><div className="business-summary">{data.dayTotals.map(r=><span key={r.label}>{r.label} <strong>{cashMoney(r.amount)}</strong></span>)}</div></section>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={blocked} onClick={onClose}>Schließen</button></div></Modal>{exportPlan&&data&&<CashExportPlan data={data} onClose={()=>setExportPlan(false)}/>} {mail&&data&&<CashflowMail data={data} onClose={()=>setMail(false)}/>} {edit&&data&&<CashflowEdit row={edit} data={data} onClose={()=>setEdit(null)} onSaved={d=>{setData(d);setQuery(d.query);setEdit(null);}}/>}{order!==null&&<OrderWorkbench id={order} onClose={()=>setOrder(null)} onSaved={()=>setData(null)}/>}</>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post} from './api';
|
||||
import {cashMoney,type CashQuery,type CashRow,type CashResult} from './Cashflow';
|
||||
type Kind='MODE'|'COUNTER'|'BOOK_INSTALLATION'|'REMOVE_INSTALLATION';
|
||||
type Input={query:CashQuery;kind:Kind;job:number;payment:number;mode:number|null};
|
||||
type Preview={input:Input;revision:string;action:string;job:number;mode:string;net:number;gross:number;booked:string;payments:{id:number;label:string;gross:number}[]};
|
||||
type Command={requestId:string;revision:string;input:Input;confirmed:boolean};
|
||||
export function CashflowEdit({row,data,onClose,onSaved}:{row:CashRow;data:CashResult;onClose:()=>void;onSaved:(data:CashResult)=>void}){
|
||||
const [kind,setKind]=useState<Kind>('MODE'),[payment,setPayment]=useState(''),[mode,setMode]=useState(''),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[error,setError]=useState(''),[ask,dialog]=useDiscardConfirmation();const sending=useRef(false),pending=useRef<Command|null>(null);const payments=row.allPayments??row.payments,open=payments.filter(p=>!p.collection&&!p.exported),needsPayment=kind==='MODE'||kind==='REMOVE_INSTALLATION',needsMode=kind==='MODE'||kind==='BOOK_INSTALLATION';
|
||||
function invalidate(){setPreview(null);setConfirmed(false);pending.current=null;setError('');}
|
||||
async function check(){if(sending.current||uncertain)return;sending.current=true;setBusy(true);invalidate();try{setPreview(await post<Preview>('/cashflow/actions/preview',{query:data.query,kind,job:row.job,payment:needsPayment?Number(payment):0,mode:needsMode?Number(mode):null}));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!preview||!confirmed||sending.current)return;pending.current??={requestId:crypto.randomUUID(),revision:preview.revision,input:preview.input,confirmed:true};sending.current=true;setBusy(true);setError('');try{const result=await post<CashResult>('/cashflow/actions',pending.current);pending.current=null;setUncertain(false);onSaved(result);}catch(e){const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){setPreview(null);setConfirmed(false);pending.current=null;}setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function close(){if(busy||uncertain)return;if(!(payment||mode||preview)||await ask('Ungespeicherte Kassenänderung verwerfen?'))onClose();}
|
||||
return <><Modal title={`Kassenänderung · Auftrag #${row.job}`} wide onClose={()=>void close()}><div className="modal-body admin-dialog-body">{error&&<p className="alert error" role="alert">{error}</p>}<p>Offener Gesamtbetrag: <strong>{cashMoney(row.remaining)}</strong>. Erfasste Zahlungen:</p><div className="table-scroll"><table className="data-table"><thead><tr><th>Zahlung</th><th>Zahlart</th><th>Brutto</th><th>Bereich</th><th>Buchung / Export</th></tr></thead><tbody>{payments.map(p=><tr key={p.id}><td>#{p.id}</td><td>{data.modes.find(m=>m.id===p.mode)?.label||`Zahlart #${p.mode}`}</td><td>{cashMoney(p.gross)}</td><td>{p.counter?'Kasse':'Monteur-Inkasso'}</td><td>{p.booked}<small>{p.collection?`Sammlung ${p.collection}`:p.exported||'Offen'}</small></td></tr>)}</tbody></table></div>
|
||||
<fieldset className="business-fieldset" disabled={busy||uncertain}><div className="form-grid"><Field label="Kassenaktion"><select value={kind} onChange={e=>{setKind(e.target.value as Kind);setPayment('');setMode('');invalidate();}}><option value="MODE">Gebuchte Zahlart ändern</option><option value="COUNTER" disabled={!open.some(p=>!p.counter&&p.net!==0)}>Monteur-Inkasso in Kasse umwandeln</option>{row.installation&&<><option value="BOOK_INSTALLATION" disabled={row.remaining===0}>Offenen Montagebetrag in die Kasse einbuchen</option><option value="REMOVE_INSTALLATION" disabled={!open.some(p=>p.counter)}>Montagebuchung aus der Kasse zurücknehmen</option></>}</select></Field>{needsPayment&&<Field label="Zahlung"><select value={payment} onChange={e=>{setPayment(e.target.value);invalidate();}}><option value="">Bitte wählen</option>{open.filter(p=>kind!=='REMOVE_INSTALLATION'||p.counter).map(p=><option key={p.id} value={p.id}>#{p.id} · {cashMoney(p.gross)} · {data.modes.find(m=>m.id===p.mode)?.label||p.mode}</option>)}</select></Field>}{needsMode&&<Field label="Neue Zahlart"><select value={mode} onChange={e=>{setMode(e.target.value);invalidate();}}><option value="">Bitte wählen</option>{data.modes.map(m=><option key={m.id} value={m.id}>{m.label}</option>)}</select></Field>}</div>
|
||||
<p className="form-hint">{kind==='COUNTER'?'Alle offenen, noch nicht exportierten Monteurzahlungen dieses Auftrags werden der Kasse zugeordnet. Beträge bleiben erhalten.':kind==='BOOK_INSTALLATION'?'Der tatsächliche Restbetrag wird zum gewählten Kassentag um 23:59:59 Uhr eingebucht.':kind==='REMOVE_INSTALLATION'?'Die ausgewählte interne Montagebuchung wird entfernt. Der offene Auftragsbetrag erhöht sich entsprechend. Die Rücknahme wird protokolliert.':'Nur die ausgewählte Zahlung erhält die neue Zahlart. Betrag und Buchungszeit bleiben erhalten.'}</p><button className="button" disabled={needsPayment&&!payment||needsMode&&!mode} onClick={()=>void check()}>Änderung prüfen</button></fieldset>
|
||||
{preview&&<section className="business-section"><h3>{preview.action}</h3><p>Auftrag #{preview.job} · {cashMoney(preview.gross)} brutto{preview.mode?` · ${preview.mode}`:''}{preview.booked?` · ${preview.booked}`:''}</p>{preview.payments.map(p=><p key={p.id}>Zahlung #{p.id}: {p.label} · {cashMoney(p.gross)}</p>)}<label className="checkbox-label"><input type="checkbox" checked={confirmed} disabled={busy||uncertain} onChange={e=>setConfirmed(e.target.checked)}/>Kassenänderung bestätigen</label></section>}{uncertain&&<p className="alert info">Das Ergebnis ist noch unbestätigt. Erneut abrufen verwendet dieselbe Vorgangskennung.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button primary" disabled={busy||!preview||!confirmed} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Kassenänderung speichern'}</button></div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
import type {CashResult} from './Cashflow';
|
||||
type Delivery={token:string;state:string;startedAt:string;subject:string;to:string[];message:string};
|
||||
type Page={page:number;pages:number;png:string};
|
||||
type Preview={token:string;from:string;to:string[];subject:string;body:string;filename:string;pdf:string;page:Page};
|
||||
type Options={from:string;to:string[];subject:string;body:string;configured:boolean;history:Delivery[]};
|
||||
export function CashflowMail({data,onClose}:{data:CashResult;onClose:()=>void}){
|
||||
const [options,setOptions]=useState<Options|null>(null),[subject,setSubject]=useState(''),[body,setBody]=useState(''),[preview,setPreview]=useState<Preview|null>(null),[page,setPage]=useState<Page|null>(null),[pdfUrl,setPdfUrl]=useState(''),[result,setResult]=useState<Delivery|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[recipients,setRecipients]=useState(false),[content,setContent]=useState(false),[exported,setExported]=useState(false);const active=useRef(false);
|
||||
useEffect(()=>{let mounted=true;post<Options>('/cashflow/mail/options',data.query).then(o=>{if(mounted){setOptions(o);setSubject(o.subject);setBody(o.body);}}).catch(e=>{if(mounted)setError(e.message);});return()=>{mounted=false;};},[data]);
|
||||
useEffect(()=>{setPage(preview?.page||null);if(!preview){setPdfUrl('');return;}const bytes=Uint8Array.from(atob(preview.pdf),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:'application/pdf'}));setPdfUrl(url);return()=>URL.revokeObjectURL(url);},[preview]);
|
||||
const locked=busy||uncertain||stale||!!result;
|
||||
async function prepare(){if(active.current||locked||!options)return;active.current=true;setBusy(true);setError('');setPreview(null);setRecipients(false);setContent(false);setExported(false);try{setPreview(await post<Preview>('/cashflow/mail/preview',{query:data.query,revision:data.revision,subject,body}));}catch(e){setError((e as Error).message);if(e instanceof ApiError&&e.status===409)setStale(true);}finally{active.current=false;setBusy(false);}}
|
||||
async function send(){if(active.current||!preview||!recipients||!content||!exported||stale||result)return;active.current=true;setBusy(true);setError('');try{setResult(await post<Delivery>(`/cashflow/mail/${preview.token}`,{recipientsChecked:true,contentChecked:true,exportChecked:true}));setUncertain(false);}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{active.current=false;setBusy(false);}}
|
||||
async function turnPage(n:number){if(active.current||!preview)return;active.current=true;setBusy(true);setError('');try{setPage(await request<Page>(`/cashflow/mail/${preview.token}/page?page=${n}`));}catch(e){setError((e as Error).message);}finally{active.current=false;setBusy(false);}}
|
||||
function close(){if(!active.current&&!uncertain)onClose();}
|
||||
return <Modal title="Kassen-PDF per E-Mail" wide onClose={close}><div className="modal-body admin-dialog-body"><p>{data.title} · {data.customer} · {data.query.from} bis {data.query.to}</p>{error&&<p className="alert error" role="alert">{error}</p>}{options&&<><p>Von: {options.from}<br/>An: {options.to.join(', ')}</p>{!options.configured&&<p className="alert info">Der SMTP-Versand ist nicht konfiguriert.</p>}{!!options.history.length&&<details open><summary>Deine letzten Versandversuche für diese Auswahl</summary>{options.history.map(h=><p key={h.token}>{h.startedAt} · {h.state==='SENT'?'Vom SMTP-Server angenommen':'Übergabe nicht bestätigt'} · {h.to.join(', ')}</p>)}</details>}<Field label="Betreff"><input maxLength={250} disabled={locked||!!preview} value={subject} onChange={e=>setSubject(e.target.value)}/></Field><Field label="Mailtext"><textarea rows={4} maxLength={20000} disabled={locked||!!preview} value={body} onChange={e=>setBody(e.target.value)}/></Field><p className="form-hint">Empfänger kommen aus den Kassen-Mailvorgaben. Der Versand setzt einen abgeschlossenen Einnahmenexport voraus. PDF und Empfänger vor dem Senden prüfen.</p><button className="button" disabled={locked||!options.configured||!!preview} onClick={()=>void prepare()}>Versandvorschau erstellen</button></>}
|
||||
{preview&&<><section className="business-section"><h3>{preview.subject}</h3><p style={{whiteSpace:'pre-wrap'}}>{preview.body}</p><p>An: {preview.to.join(', ')}</p><label className="admin-check"><input type="checkbox" disabled={locked} checked={recipients} onChange={e=>setRecipients(e.target.checked)}/>Empfänger und bisherige Versandversuche geprüft</label><label className="admin-check"><input type="checkbox" disabled={locked} checked={content} onChange={e=>setContent(e.target.checked)}/>PDF-Inhalt geprüft; alle aufgeführten Kundendaten sind für diese Empfänger bestimmt</label><label className="admin-check"><input type="checkbox" disabled={locked} checked={exported} onChange={e=>setExported(e.target.checked)}/>Einnahmenexport einschließlich erforderlicher Dateiübergabe abgeschlossen</label></section><a className="button" href={pdfUrl} download={preview.filename}>PDF-Anhang herunterladen</a><div className="admin-toolbar"><button className="button" disabled={busy||!page||page.page===0} onClick={()=>void turnPage(page!.page-1)}>Vorherige PDF-Seite</button><span>PDF-Seite {(page?.page??0)+1} / {page?.pages??0}</span><button className="button" disabled={busy||!page||page.page+1>=page.pages} onClick={()=>void turnPage(page!.page+1)}>Nächste PDF-Seite</button></div>{page&&<img className="rollcard-pdf-page" src={`data:image/png;base64,${page.png}`} alt={`Kassen-PDF · Seite ${page.page+1} von ${page.pages}`}/>}</>}
|
||||
{stale&&<p>Bitte den Dialog schließen, die Kassenübersicht neu laden und erneut prüfen.</p>}{uncertain&&<p role="status">Die Antwort ist ausgeblieben. Erneutes Abrufen verwendet dieselbe Versandkennung und löst keinen zweiten SMTP-Versuch aus.</p>}{result&&<p className={result.state==='SENT'?'alert success':'alert info'} role="status">{result.message}</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={close}>Schließen</button>{preview&&!result&&<button className="button primary" disabled={busy||stale||!recipients||!content||!exported} onClick={()=>void send()}>{uncertain?'Ergebnis erneut abrufen':'Kassen-PDF jetzt senden'}</button>}</div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {AssignmentOrders} from "./AssignmentOrders";
|
||||
import {AssignmentDriver} from "./AssignmentDriver";
|
||||
import {useEffect,useMemo,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Field} from './FormElements';
|
||||
import type {Vehicle} from './OrderCourierAssignment';
|
||||
type Candidate=Vehicle&{headquarters:string;vehicleTypeName:string;group:string;groupRank:number;favourite:boolean;previouslyOffered:boolean;activeOrders:number;earliestOrder:string;reportedOccupied:boolean;warnings:string[];blockers:string[]};
|
||||
type Result={mode:string;startPostalCode:string;area:string;occupiedFirst:boolean;warnings:string[];items:Candidate[]};
|
||||
const time=(value:string)=>{if(!value)return '–';const date=new Date(value.replace(' ','T'));return Number.isNaN(date.getTime())?value:date.toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'});};
|
||||
export function ClassicCandidateList({id,refresh,selected,onSelect}:{id:number;refresh:number;selected?:number;onSelect:(v:Vehicle)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<Result|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[reload,setReload]=useState(0),[query,setQuery]=useState(''),[group,setGroup]=useState(''),[favourite,setFavourite]=useState(false),[free,setFree]=useState(false),[page,setPage]=useState(1);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setData(null);setError('');request<Result>(`/orders/${id}/courier-assignment/classic-candidates`,{signal:c.signal}).then(d=>{if(!c.signal.aborted){setData(d);setPage(1);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[id,refresh,reload]);
|
||||
const rows=useMemo(()=>(data?.items||[]).map((v,index)=>({...v,rank:index+1})).filter(v=>(!favourite||v.favourite)&&(!free||!v.occupied)&&(!group||v.group===group)&&[v.sid,v.driverName||'',v.name,v.plate,v.postalCode,v.headquarters].some(text=>text.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de')))),[data,query,group,favourite,free]);
|
||||
const groups=[...new Set(data?.items.map(v=>v.group)||[])],pages=Math.max(1,Math.ceil(rows.length/50)),currentPage=Math.min(page,pages);
|
||||
return <section className="business-section"><h3>CLASSIC-Kurierrangliste</h3><p>Favoriten, Vermittlungsbereich, Nachbarschaften und weitere verfügbare Kuriere. Die Zuweisung wird anschließend erneut geprüft.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Rangfolge und laufende Aufträge werden ermittelt …</p>}
|
||||
<button className="button" type="button" disabled={busy} onClick={()=>setReload(v=>v+1)}>Rangliste aktualisieren</button>
|
||||
{data&&<>{data.warnings.map(w=><p className="alert info" key={w}>{w}</p>)}<p>{uiText("Abholung ")}{data.startPostalCode}{data.area&&data.area!==data.startPostalCode?` · Bereich ${data.area}`:''} · {data.items.length} {uiText(" Fahrzeuge")}{data.occupiedFirst?' · Freie Kuriere vor belegten':''}</p>
|
||||
<div className="form-grid cols-3"><Field label="Rangliste filtern"><input value={query} onChange={e=>{setQuery(e.target.value);setPage(1);}}/></Field><Field label="Vermittlungsgruppe"><select value={group} onChange={e=>{setGroup(e.target.value);setPage(1);}}><option value="">Alle Gruppen</option>{groups.map(g=><option key={g}>{g}</option>)}</select></Field><div><label><input type="checkbox" checked={favourite} onChange={e=>{setFavourite(e.target.checked);setPage(1);}}/>Nur Favoriten</label><label><input type="checkbox" checked={free} onChange={e=>{setFree(e.target.checked);setPage(1);}}/>Nur ohne belegenden Auftrag</label></div></div>
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Rang / Kurier</th><th>Vermittlungsgruppe</th><th>Fahrzeug / Transporteur</th><th>Freimeldung</th><th>Aufträge / Hinweise</th></tr></thead><tbody>{rows.slice((currentPage-1)*50,currentPage*50).map(v=><tr key={`${v.id}:${v.courierId}`} aria-selected={selected===v.id}><td>{v.rank}. <button type="button" className={'button '+(selected===v.id?'primary':'')} disabled={!!v.blockers.length} onClick={()=>onSelect(v)} aria-label={`Ranglistenfahrzeug ${v.sid} auswählen`}>{v.sid}</button>{v.previouslyOffered&&<p className="alert info">Bereits angeboten / zurückgerufen</p>}</td><td>{v.group}{v.favourite&&<p className="form-hint">Kundenfavorit</p>}</td><td>{v.vehicleTypeName} · {v.plate}<br/>{v.name} · {v.headquarters}<AssignmentDriver value={v}/></td><td>{v.postalCode||'–'}<br/>{time(v.availableTime)}</td><td>{v.occupied?<strong>Belegt</strong>:<span>Kein belegender Auftrag</span>}{v.activeOrders>0&&<p>{v.activeOrders} laufende / vorgemerkte Aufträge<br/>Frühester: {time(v.earliestOrder)}</p>}<AssignmentOrders job={id} vehicle={v.id} courier={v.courierId} sid={v.sid} refresh={refresh+reload}/>{v.reportedOccupied&&!v.occupied&&<p className="form-hint">Als belegt gemeldet, derzeit kein Auftrag im Vorlauffenster.</p>}{v.blockers.map(b=><p className="alert error" key={b}>{b}</p>)}{v.warnings.map(w=><p className="form-hint" key={w}>{w}</p>)}</td></tr>)}</tbody></table></div>
|
||||
{!rows.length&&<p>Keine Kuriere für diese Auswahl.</p>}<div className="admin-row-actions"><button type="button" className="button" disabled={currentPage<=1} onClick={()=>setPage(v=>v-1)}>Vorherige Ranglistenseite</button><span>{currentPage} / {pages} · {rows.length} Treffer</span><button type="button" className="button" disabled={currentPage>=pages} onClick={()=>setPage(v=>v+1)}>Nächste Ranglistenseite</button></div>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ApiError,post,request} from './api';
|
||||
import {Field,Modal} from './FormElements';
|
||||
type Card={filename:string;content:string;revision:string;defaultRecipient:string;mailConfigured:boolean};
|
||||
type Preview={token:string;from:string;recipients:string[];subject:string;filename:string;content:string};
|
||||
type Delivery={token:string;state:string;recipients:string[];from:string;filename:string;startedAt:string;message:string};
|
||||
export function ContactVcard({id,headquarters,onClose,onDirtyChange}:{id:number;headquarters:number;onClose:()=>void;onDirtyChange:(value:boolean)=>void}){
|
||||
const base=`/crm/contacts/${id}/vcard`,query=`?headquarters=${headquarters}`;
|
||||
const [card,setCard]=useState<Card|null>(null),[recipients,setRecipients]=useState(''),[preview,setPreview]=useState<Preview|null>(null),[result,setResult]=useState<Delivery|null>(null),[history,setHistory]=useState<Delivery[]>([]);
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState(''),[confirmed,setConfirmed]=useState(false),[attempted,setAttempted]=useState(false);
|
||||
useEffect(()=>{onDirtyChange(busy||!!preview&&!result);return()=>onDirtyChange(false);},[busy,preview,result,onDirtyChange]);
|
||||
async function load(){setBusy(true);setError('');try{const value=await request<Card>(base+query);setCard(value);setRecipients(value.defaultRecipient);setPreview(null);setResult(null);setAttempted(false);setConfirmed(false);const events=await request<{items:Delivery[]}>(base+'/mail'+query);setHistory(events.items);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
useEffect(()=>{void load();},[id,headquarters]);
|
||||
async function prepare(){if(!card)return;setBusy(true);setError('');try{setPreview(await post<Preview>(base+'/mail/preview'+query,{revision:card.revision,recipients}));setConfirmed(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function send(){if(!preview)return;setBusy(true);setError('');setAttempted(true);try{const value=await post<Delivery>(base+'/mail/'+preview.token+query,{confirmed});setResult(value);setHistory(old=>[value,...old.filter(v=>v.token!==value.token)].slice(0,10));}catch(e){setError((e as Error).message);if(e instanceof ApiError&&e.status>=400&&e.status<500)setAttempted(false);}finally{setBusy(false);}}
|
||||
function download(){const value=preview||card;if(!value)return;const url=URL.createObjectURL(new Blob([value.content],{type:'text/vcard;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download=value.filename;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
return <Modal title={`vCard · Kontakt ${id}`} wide onClose={()=>{if(!busy)onClose();}}><div className="admin-dialog-body">
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p role="status">Kontaktkarte wird geprüft …</p>}
|
||||
{!card&&!busy&&<button className="button" onClick={()=>void load()}>Erneut laden</button>}
|
||||
{card&&<><Field label="Inhalt der Kontaktkarte"><textarea readOnly rows={12} value={preview?.content??card.content}/></Field><div className="admin-row-actions"><button className="button" disabled={busy} onClick={download}>vCard herunterladen</button>{!preview&&<button className="button" disabled={busy} onClick={()=>void load()}>Kontaktkarte neu laden</button>}</div>
|
||||
{result?<p role="status" className={result.state==='SENT'?'admin-success':'alert info'}>{result.message}</p>:preview?<><h3>E-Mail-Vorschau</h3><dl><dt>Absender</dt><dd>{preview.from}</dd><dt>Empfänger</dt><dd>{preview.recipients.join(', ')}</dd><dt>Betreff</dt><dd>{preview.subject}</dd><dt>Anhang</dt><dd>{preview.filename}</dd></dl>
|
||||
<label className="admin-check"><input type="checkbox" disabled={busy||attempted} checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Empfänger und vollständigen Inhalt der Kontaktkarte geprüft</label>
|
||||
{attempted&&<p className="alert info">Das Ergebnis ist noch nicht bestätigt. „Ergebnis abrufen“ verwendet denselben Versandvorgang. Vor einem neuen Versand bitte auch den Verlauf prüfen.</p>}
|
||||
<div className="admin-row-actions"><button className="button" disabled={busy||attempted} onClick={()=>setPreview(null)}>Empfänger bearbeiten</button><button className="button primary" disabled={busy||!confirmed} onClick={()=>void send()}>{attempted?'Ergebnis abrufen':'vCard jetzt senden'}</button></div></>:<form onSubmit={e=>{e.preventDefault();void prepare();}}><fieldset disabled={busy||!card.mailConfigured}><h3>Als E-Mail-Anhang versenden</h3><Field label="Empfänger"><input type="text" inputMode="email" value={recipients} maxLength={4000} onChange={e=>setRecipients(e.target.value)}/></Field><p className="form-hint">Vorbelegt ist Ihre eigene E-Mail-Adresse. Mehrere Empfänger mit Komma trennen. Die vollständige Kontaktkarte einschließlich privater Angaben wird angehängt.</p><button className="button primary">E-Mail-Vorschau prüfen</button></fieldset>{!card.mailConfigured&&<p className="alert info">Der E-Mail-Versand ist nicht eingerichtet. Die Kontaktkarte kann heruntergeladen werden.</p>}</form>}
|
||||
<h3>Ihre letzten Versandvorgänge</h3>{!history.length?<p>Noch kein Versand für diesen Kontakt.</p>:<ul>{history.map(h=><li key={h.token}>{new Date(h.startedAt).toLocaleString('de-DE')} · {h.recipients.join(', ')} · {h.state==='SENT'?'Vom Mailserver angenommen':'Versand nicht sicher bestätigt'}</li>)}</ul>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy} onClick={onClose}>Schließen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {ApiError,put,request} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
export type CostCenterColumn={key:string;length:number};
|
||||
export type ColumnDefinition=CostCenterColumn&{label:string};
|
||||
export type CostCenterColumns={employee:{firstName:string;name:string};headquartersId:number;columns:CostCenterColumn[];definitions:ColumnDefinition[];revision:string;canSave:boolean;warning:string};
|
||||
export function CostCenterColumnsDialog({employee,onClose,onDirtyChange,onSaved}:{employee?:number;onClose:()=>void;onDirtyChange:(v:boolean)=>void;onSaved?:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=employee?`/administration/employees/${employee}/cost-center-columns`:'/account/cost-center-columns';
|
||||
const [data,setData]=useState<CostCenterColumns|null>(null),[draft,setDraft]=useState<CostCenterColumn[]>([]),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<{requestId:string;revision:string;columns:CostCenterColumn[]}|null>(null),generation=useRef(0);
|
||||
const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&JSON.stringify(draft)!==JSON.stringify(data.columns);
|
||||
const protectedDraft=useRef(false);protectedDraft.current=dirty||busy||uncertain;
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
function apply(d:CostCenterColumns){setData(d);setDraft(d.columns);setStale(false);pending.current=null;}
|
||||
useEffect(()=>{if(protectedDraft.current)return;const c=new AbortController(),version=++generation.current;setError('');setBusy(true);request<CostCenterColumns>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted&&version===generation.current)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base]);
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Spaltenvorgaben verwerfen?'))return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');try{apply(await request<CostCenterColumns>(base));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
function change(next:CostCenterColumn[]){setDraft(next);setNotice('');pending.current=null;}
|
||||
function move(index:number,delta:number){const next=[...draft];[next[index],next[index+delta]]=[next[index+delta],next[index]];change(next);}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||sending.current||stale||!data.canSave||!uncertain&&!dirty)return;
|
||||
sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');
|
||||
if(!pending.current)pending.current={requestId:crypto.randomUUID(),revision:data.revision,columns:draft};
|
||||
try{await put(base,pending.current);pending.current=null;setUncertain(false);setNotice('Spaltenvorgaben gespeichert.');onSaved?.();try{apply(await request<CostCenterColumns>(base));}catch(e){setError('Gespeichert. Der aktuelle Stand konnte nicht geladen werden: '+(e as Error).message);setStale(true);}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
async function close(){if(sending.current||uncertain)return;if(!dirty||await discard('Ungespeicherte Spaltenvorgaben verwerfen?'))onClose();}
|
||||
return <><Modal title="Spalten der Kostenstellenauswahl" onClose={()=>void close()}><form onSubmit={save}><div className="admin-dialog-body modal-body">
|
||||
{data&&<p>{data.employee.firstName} {data.employee.name} · Vorgaben für Niederlassung {data.headquartersId}</p>}
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{data?.warning&&<p className="alert info">{data.warning}</p>}
|
||||
{!data&&busy&&<p>Spaltenvorgaben werden geladen …</p>}
|
||||
{data&&<fieldset disabled={busy||uncertain||stale||!data.canSave}><legend>Reihenfolge und Anzeigelänge</legend>
|
||||
<p>Bis zu 21 Spalten. Länge 0 zeigt den vollständigen Text. Gekürzte Texte bleiben in der Auswahl vollständig als Hinweis verfügbar.</p>
|
||||
<div className="column-layout-rows">{draft.map((column,i)=><div className="column-layout-row" key={i}>
|
||||
<span>{i+1}.</span><Field label={`Spalte ${i+1}`}><select value={column.key} onChange={e=>change(draft.map((v,n)=>n===i?{...v,key:e.target.value}:v))}>{data.definitions.map(d=><option key={d.key} value={d.key}>{d.label}</option>)}</select></Field>
|
||||
<Field label={`Länge ${i+1}`}><input type="number" min={0} max={50} required value={column.length} onChange={e=>change(draft.map((v,n)=>n===i?{...v,length:Number(e.target.value)}:v))}/></Field>
|
||||
<button type="button" className="button" aria-label={`Spalte ${i+1} nach oben`} disabled={i===0} onClick={()=>move(i,-1)}>↑</button><button type="button" className="button" aria-label={`Spalte ${i+1} nach unten`} disabled={i===draft.length-1} onClick={()=>move(i,1)}>↓</button><button type="button" className="button" aria-label={`Spalte ${i+1} entfernen`} disabled={draft.length===1} onClick={()=>change(draft.filter((_,n)=>n!==i))}>Entfernen</button>
|
||||
</div>)}</div><div className="admin-toolbar"><button type="button" className="button" disabled={draft.length>=21} onClick={()=>change([...draft,{key:'cmp_comp',length:30}])}>Spalte hinzufügen</button><button type="button" className="button" onClick={()=>change(data.definitions.slice(0,14).map(({key,length})=>({key,length})))}>Standardbelegung übernehmen</button></div>
|
||||
</fieldset>}
|
||||
{uncertain&&<p className="alert info" role="status">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Änderungen erneut prüfen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" type="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button" type="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={!data||!data.canSave||busy||stale||!uncertain&&!dirty}>{uncertain?'Ergebnis erneut abrufen':'Spaltenvorgaben speichern'}</button></div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useCallback,useEffect,useMemo,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {X} from 'lucide-react';
|
||||
import {useEntryApi} from './EntryScope';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {CostCenterColumnsDialog,type CostCenterColumn,type ColumnDefinition} from './CostCenterColumns';
|
||||
import {Statistics} from './Statistics';
|
||||
import {Reports} from './Reports';
|
||||
export type CostCenter={id:number;customerId:number;customerNumber:string;customerName:string;name:string;defaultPayerId:number|null;fixedDiscount:number;taxId:number|null};
|
||||
type Item={value:CostCenter;headquartersId:number;cells:Record<string,string>};
|
||||
type Result={items:Item[];columns:CostCenterColumn[];definitions:ColumnDefinition[];total:number;offset:number;limit:number;warning:string;canStatistics:boolean;canReports:boolean};
|
||||
const labelOf=(v:CostCenter)=>`${v.customerNumber} · ${v.customerName} · ${v.name}`;
|
||||
export function CostCenterLookup({label,value,onSelect,disabled=false,shortcut,payer=false}:{label:string;value:CostCenter|null;onSelect:(v:CostCenter|null)=>void;disabled?:boolean;shortcut?:string;payer?:boolean}){
|
||||
const [open,setOpen]=useState(false);
|
||||
return <div className="entry-lookup">{value?<div className="entry-selected"><span><small>{label}</small><strong>{labelOf(value)}</strong></span><button type="button" className="icon-button" disabled={disabled} data-entry-key={shortcut} aria-label={`${label} ändern`} onClick={()=>setOpen(true)}><X size={16}/></button></div>:<Field label={label}><button type="button" className="button" disabled={disabled} data-entry-key={shortcut} onClick={()=>setOpen(true)}>Kostenstelle suchen und auswählen</button></Field>}
|
||||
{open&&createPortal(<Picker title={label} payer={payer} onClose={()=>setOpen(false)} onSelect={v=>{onSelect(v);setOpen(false);}}/>,document.body)}</div>;
|
||||
}
|
||||
function Picker({title,payer,onClose,onSelect}:{title:string;payer:boolean;onClose:()=>void;onSelect:(v:CostCenter|null)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const {request}=useEntryApi();const [query,setQuery]=useState(''),[prefix,setPrefix]=useState(false),[filters,setFilters]=useState<Record<string,string>>({}),[sort,setSort]=useState('cmp_comp'),[descending,setDescending]=useState(false),[offset,setOffset]=useState(0),[refresh,setRefresh]=useState(0);
|
||||
const [data,setData]=useState<Result|null>(null),[busy,setBusy]=useState(true),[error,setError]=useState(''),[columns,setColumns]=useState(false),[nestedDirty,setNestedDirty]=useState(false),[action,setAction]=useState<{kind:'statistic'|'cs_report';item:Item}|null>(null);
|
||||
const [discard,discardDialog]=useDiscardConfirmation();const dirty=useCallback((v:boolean)=>setNestedDirty(v),[]);
|
||||
const body=useMemo(()=>JSON.stringify({q:query,prefix,filters,sort,descending,offset,payer}),[query,prefix,filters,sort,descending,offset,payer]);
|
||||
useEffect(()=>{if(columns||action)return;const c=new AbortController();setBusy(true);setError('');const timer=setTimeout(()=>request<Result>('/orders/entry/cost-centers/search',{method:'POST',body,signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted){setData(null);setError(e.message);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);}),250);return()=>{c.abort();clearTimeout(timer);};},[request,body,refresh,columns,action]);
|
||||
const context=useMemo(()=>action?{id:action.item.value.customerId,label:`${action.item.value.customerNumber} · ${action.item.value.customerName}`,headquartersId:action.item.headquartersId}:undefined,[action]);
|
||||
async function closeAction(){if(!nestedDirty||await discard('Ungespeicherte Berichtsänderungen verwerfen?')){setAction(null);setNestedDirty(false);}}
|
||||
function search(key:string,value:string){setFilters(v=>({...v,[key]:value}));setOffset(0);}
|
||||
const activeFilters=Object.entries(filters).filter(([,v])=>v.trim());
|
||||
return <><Modal wide title={`${title} auswählen`} onClose={onClose}><div className="admin-dialog-body modal-body">
|
||||
<div className="admin-toolbar"><Field label="Kostenstellen suchen"><input type="search" autoFocus maxLength={200} value={query} placeholder="Firma, Kundennummer, Kostenstelle, Freitext oder Telefon" onChange={e=>{setQuery(e.target.value);setOffset(0);}}/></Field><Field label="Suchmodus"><select value={prefix?'prefix':'part'} onChange={e=>{setPrefix(e.target.value==='prefix');setOffset(0);}}><option value="part">{uiText("Teilwort")}</option><option value="prefix">{uiText("Präfix")}</option></select></Field><button type="button" className="button" onClick={()=>{setFilters({});setQuery('');setOffset(0);}}>{uiText("Felder zurücksetzen")}</button><button type="button" className="button" onClick={()=>setColumns(true)}>Spalten anpassen</button></div>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{data?.warning&&<p className="alert info">{data.warning}</p>}
|
||||
{!!activeFilters.length&&<div className="admin-toolbar" aria-label="Aktive Spaltenfilter">{activeFilters.map(([key,v])=><button type="button" className="button" key={key} onClick={()=>search(key,'')}>{data?.definitions.find(d=>d.key===key)?.label??key}: {v} ×</button>)}</div>}
|
||||
{busy&&<p role="status">Suche läuft …</p>}
|
||||
{data&&<><p role="status">{data.total} Treffer · {data.total?`${offset+1}–${Math.min(offset+data.items.length,data.total)}`:'0'}{payer?' · Nur interne Rechnungszahler':''}</p><div className="admin-table-wrap cost-center-results" aria-busy={busy}><table className="admin-table"><thead><tr><th>Auswahl</th>{data.columns.map((c,i)=><th key={i}>{['statistic','cs_report','cmp_authenticated_img'].includes(c.key)?data.definitions.find(d=>d.key===c.key)?.label:<button type="button" className="text-link" onClick={()=>{setDescending(sort===c.key?!descending:false);setSort(c.key);setOffset(0);}}>{data.definitions.find(d=>d.key===c.key)?.label}{sort===c.key?(descending?' ↓':' ↑'):''}</button>}</th>)}</tr><tr><th/><>{data.columns.map((c,i)=><th key={i}>{!['statistic','cs_report','cmp_authenticated_img'].includes(c.key)&&<input aria-label={`${data.definitions.find(d=>d.key===c.key)?.label} filtern`} type="search" maxLength={200} size={c.length||20} style={{width:`${Math.max(5,c.length||20)}ch`}} value={filters[c.key]??''} onChange={e=>search(c.key,e.target.value)}/>}</th>)}</></tr></thead><tbody>{data.items.map((item,n)=><tr key={`${offset+n}:${item.value.id}`}><td><button type="button" className="button" disabled={busy} aria-label={`${labelOf(item.value)} auswählen`} onClick={()=>onSelect(item.value)}>Auswählen</button></td>{data.columns.map((c,i)=><td key={i}>{c.key==='statistic'||c.key==='cs_report'?<button type="button" className="text-link" disabled={busy||(c.key==='statistic'?!data.canStatistics:!data.canReports)} title={c.key==='statistic'&&!data.canStatistics?'Statistikrecht erforderlich':c.key==='cs_report'&&!data.canReports?'Kunden- und Berichtsrecht erforderlich':undefined} onClick={()=>setAction({kind:c.key as 'statistic'|'cs_report',item})}>{c.key==='statistic'?uiText("Statistik"):uiText("Berichte")}</button>:<span title={item.cells[c.key]??''}>{c.length?Array.from(item.cells[c.key]??'').slice(0,c.length).join(''):item.cells[c.key]||'–'}</span>}</td>)}</tr>)}</tbody></table></div><div className="admin-toolbar"><button type="button" className="button" disabled={busy||offset===0} onClick={()=>setOffset(Math.max(0,offset-50))}>Vorherige Seite</button><button type="button" className="button" disabled={busy||offset+50>=data.total} onClick={()=>setOffset(offset+50)}>Nächste Seite</button></div></>}
|
||||
</div><div className="modal-footer"><button type="button" className="button" onClick={()=>onSelect(null)}>Zuordnung entfernen</button><button type="button" className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>
|
||||
{columns&&<CostCenterColumnsDialog onClose={()=>{setColumns(false);setNestedDirty(false);}} onDirtyChange={dirty} onSaved={()=>setRefresh(v=>v+1)}/>}
|
||||
{action&&context&&<Modal wide title={action.kind==='statistic'?'Kundenstatistik':'Kundenberichte'} onClose={()=>void closeAction()}><div className="admin-dialog-body modal-body">{action.kind==='statistic'?<Statistics initialCustomer={{number:action.item.value.customerNumber,label:context.label,headquartersId:context.headquartersId}}/>:<Reports kind="CUSTOMER" refresh={0} initialObject={context} onDirtyChange={dirty}/>}</div></Modal>}{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
|
||||
type Availability={online:boolean;vehicleType:string;warning:string};
|
||||
export function CourierAvailability({courier,refreshKey}:{courier:number;refreshKey?:string}){
|
||||
const [data,setData]=useState<Availability|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(true),[version,setVersion]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setData(null);setError('');
|
||||
request<Availability>(`/master-data/couriers/${courier}/availability`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});
|
||||
return()=>c.abort();
|
||||
},[courier,refreshKey,version]);
|
||||
return <section className="business-section" aria-label="Verbindungsstatus des Transporteurs"><h3>Derzeit online</h3>
|
||||
{busy&&<p role="status">Status wird geladen …</p>}{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
{data&&<><p role="status"><strong>{data.online?'Ja':'Nein'}</strong>{data.online&&data.vehicleType&&` · ${data.vehicleType}`}</p>{data.warning&&<p role="status">{data.warning}</p>}</>}
|
||||
<p className="form-hint">Gespeicherter Verbindungsstatus. Eine vorgemerkte Geräte-Abmeldung ist erst nach Rückmeldung des Geräts wirksam.</p>
|
||||
<button type="button" className="button" disabled={busy} onClick={()=>setVersion(v=>v+1)}>Verbindungsstatus aktualisieren</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
type Column={key:string;label:string;type:string};
|
||||
type Option={id:number;label:string};
|
||||
type Options={headquarters:Option[];vehicleTypes:Option[];columns:Column[];limit:number;from:string;to:string;copyNumber:boolean;refreshToggle:boolean;warning:string};
|
||||
type Query={from:string;to:string;numberFrom:number|null;numberTo:number|null;headquarters:number[];vehicleType:number;filters:Record<string,string>;sort:string;descending:boolean;page:number};
|
||||
type Report={rows:{id:number;cells:Record<string,string>}[];columns:Column[];page:number;limit:number;hasNext:boolean;warning:string};
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string)=>s?new Date(s).toLocaleString('de-DE'):'–';
|
||||
const filters=[['street','Straße'],['zip','PLZ'],['city','Ort'],['company','Firma'],['person','Person'],['remark','Bemerkung']];
|
||||
export function CourierBourse({headquarters,onClose}:{headquarters:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query|null>(null),[applied,setApplied]=useState<Query|null>(null),[report,setReport]=useState<Report|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[automatic,setAutomatic]=useState(true),[refresh,setRefresh]=useState(0),[job,setJob]=useState<number|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();request<Options>('/courier-portal/bourse',{signal:c.signal}).then(o=>{if(c.signal.aborted)return;setOptions(o);const q={from:o.from,to:o.to,numberFrom:null,numberTo:null,headquarters:[headquarters],vehicleType:0,filters:{},sort:'jb_ordertime',descending:false,page:0};setQuery(q);setApplied(q);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[headquarters]);
|
||||
useEffect(()=>{if(!applied)return;const c=new AbortController();setBusy(true);setError('');request<Report>('/courier-portal/bourse/query',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(v=>{if(!c.signal.aborted)setReport(v);}).catch(e=>{if(!c.signal.aborted){setError(e.message);setReport(null);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied,refresh]);
|
||||
useEffect(()=>{if(!automatic||!applied||busy||job!==null)return;const timer=setInterval(()=>setRefresh(n=>n+1),60000);return()=>clearInterval(timer);},[automatic,applied,busy,job]);
|
||||
function show(e:FormEvent){e.preventDefault();if(query){const q={...query,page:0};setQuery(q);setApplied(q);}}
|
||||
function page(n:number){if(applied){setApplied({...applied,page:n});setQuery(q=>q?{...q,page:n}:q);}}
|
||||
function sort(key:string){if(!applied)return;const next={...applied,sort:key,descending:applied.sort===key?!applied.descending:false,page:0};setApplied(next);setQuery(q=>q?{...q,sort:next.sort,descending:next.descending,page:0}:q);}
|
||||
const [filtersOpen,setFiltersOpen]=useState(true);
|
||||
const locked=busy||job!==null;
|
||||
return <main style={{maxWidth:1500,margin:'0 auto',padding:24}}><header className="admin-title"><div><h1>Auftragsbörse</h1><p>Offene Börsenaufträge der für Ihren Transporteur freigegebenen Kunden.</p></div><button className="button" disabled={locked} onClick={onClose}>Zur Auftragsübersicht</button></header>{error&&<p className="alert error" role="alert">{error}</p>}{!options&&!error&&<p>Börse wird geladen …</p>}{options&&query&&<section className="panel" style={{padding:20}}><button className="button" disabled={locked} aria-expanded={filtersOpen} onClick={()=>setFiltersOpen(v=>!v)}>Suchfilter {filtersOpen?'einklappen':'ausklappen'}</button><form hidden={!filtersOpen} onSubmit={show}><fieldset disabled={locked}><div className="admin-toolbar"><Field label="Von"><input type="date" value={query.from} required={query.numberFrom===null&&query.numberTo===null} onChange={e=>setQuery({...query,from:e.target.value})}/></Field><Field label="Bis"><input type="date" value={query.to} required={query.numberFrom===null&&query.numberTo===null} onChange={e=>setQuery({...query,to:e.target.value})}/></Field><Field label="Auftrag von"><input type="number" min={1} value={query.numberFrom??''} onChange={e=>{const n=e.target.value?Number(e.target.value):null;setQuery({...query,numberFrom:n,...(options.copyNumber?{numberTo:n}:{})});}}/></Field><Field label="Auftrag bis"><input type="number" min={1} value={query.numberTo??''} onChange={e=>setQuery({...query,numberTo:e.target.value?Number(e.target.value):null})}/></Field><Field label="Fahrzeugtyp"><select value={query.vehicleType} onChange={e=>setQuery({...query,vehicleType:Number(e.target.value)})}><option value={0}>Alle bis Typ 100</option>{options.vehicleTypes.map(v=><option value={v.id} key={v.id}>{v.label}</option>)}</select></Field></div>{(query.numberFrom!==null||query.numberTo!==null)&&<p className="form-hint">Der Auftragsnummernbereich ersetzt den Datumsfilter.</p>}<div className="admin-toolbar">{filters.map(([key,label])=><Field label={label} key={key}><input value={query.filters[key]||''} maxLength={200} onChange={e=>setQuery({...query,filters:{...query.filters,[key]:e.target.value}})}/></Field>)}</div><fieldset><legend>Niederlassungen</legend><div className="admin-toolbar"><button className="button" type="button" onClick={()=>setQuery({...query,headquarters:options.headquarters.map(h=>h.id)})}>{uiText("Alle")}</button><button className="button" type="button" onClick={()=>setQuery({...query,headquarters:[]})}>{uiText("Keine")}</button><button className="button" type="button" onClick={()=>setQuery({...query,headquarters:[headquarters]})}>Eigene</button>{options.headquarters.map(h=><label key={h.id} className="admin-check"><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||h.id}</label>)}</div></fieldset><div className="admin-toolbar"><button className="button primary">{uiText("Anzeigen")}</button><button className="button" type="button" onClick={()=>{const q={...query,from:options.from,to:options.to,numberFrom:null,numberTo:null,headquarters:[headquarters],vehicleType:0,filters:{},page:0};setQuery(q);setApplied(q);}}>Filter zurücksetzen</button>{options.refreshToggle?<label className="admin-check"><input type="checkbox" checked={automatic} onChange={e=>setAutomatic(e.target.checked)}/>Alle 60 Sekunden aktualisieren</label>:<span className="form-hint">Automatische Aktualisierung alle 60 Sekunden.</span>}</div></fieldset></form></section>}{busy&&<p>Aufträge werden geladen …</p>}{report&&<section className="panel" style={{padding:20}}>{report.warning&&<p className="alert info">{report.warning}</p>}<div className="admin-toolbar"><button className="button" disabled={locked||report.page===0} onClick={()=>page(report.page-1)}>Zurück</button><span>Seite {report.page+1} · {report.rows.length} Aufträge · bis zu {report.limit} je Seite</span><button className="button" disabled={locked||!report.hasNext} onClick={()=>page(report.page+1)}>Weiter</button></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr>{report.columns.map(c=><th key={c.key}><button className="button" disabled={locked} onClick={()=>sort(c.key)}>{c.label}{applied?.sort===c.key?(applied.descending?' ↓':' ↑'):''}</button></th>)}<th>{uiText("Details")}</th></tr></thead><tbody>{report.rows.map(r=><tr key={r.id}>{report.columns.map(c=><td key={c.key}>{c.type==='money'?money(Number(r.cells[c.key])):c.type==='date'?date(r.cells[c.key]):r.cells[c.key]}</td>)}<td><button className="button" disabled={locked} onClick={()=>setJob(r.id)}>Auftrag #{r.id} ansehen</button></td></tr>)}</tbody></table></div>{!report.rows.length&&<p>Keine freigegebenen Börsenaufträge für diese Auswahl.</p>}</section>}{job!==null&&<BourseDetail job={job} onClose={()=>setJob(null)}/>}</main>;
|
||||
}
|
||||
function BourseDetail({job,onClose}:{job:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<{id:number;ordered:string;price:number;freight:number;information:string;stops:{sequence:number;company:string;additional:string;address:string;person:string;remark:string}[]}|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();request<typeof data>('/courier-portal/bourse/detail',{method:'POST',body:JSON.stringify({job}),signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[job]);
|
||||
return <Modal title={`Börsenauftrag #${job}`} wide onClose={onClose}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{!data&&!error&&<p>Auftrag wird geladen …</p>}{data&&<><p>{date(data.ordered)} · Preis {money(data.price)} · Fuhrlohn {money(data.freight)}</p>{data.information&&<p style={{whiteSpace:'pre-wrap'}}>Dispo-Info: {data.information}</p>}{data.stops.map((s,i)=><section key={i} style={{marginBottom:20}}><h3>{s.sequence}. {s.company}</h3><p>{s.additional}</p><p>{s.address}</p><p>{s.person}</p><p style={{whiteSpace:'pre-wrap'}}>{s.remark}</p></section>)}</>}</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,put,request} from './api';
|
||||
type View={id:number;label:string;phone:string;editable:boolean;enabled:boolean;revision:string;options:{id:number;label:string;available?:boolean;requiresTransfer?:boolean;assignment?:string}[];optionsError:string;pending:null|{id:number;phone:string;state:string;attempts:number;message:string;revision:string}};
|
||||
export function CourierDevicePhone({courier,onClose}:{courier:number;onClose:()=>void}){
|
||||
const base=`/master-data/couriers/${courier}/device-phone`;
|
||||
const [view,setView]=useState<View|null>(null),[phone,setPhone]=useState(''),[selected,setSelected]=useState(0),[transfer,setTransfer]=useState(false),[busy,setBusy]=useState(true),[error,setError]=useState(''),[notice,setNotice]=useState(''),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[version,setVersion]=useState(0),[discard,dialog]=useDiscardConfirmation();
|
||||
const sending=useRef(false),pending=useRef<{retry:boolean;body:unknown}|null>(null);
|
||||
function accept(v:View){setTransfer(false);setView(v);setPhone(v.phone);setSelected(v.options.find(o=>o.label===v.phone)?.id||0);}
|
||||
const dirty=!!view&&(phone!==view.phone||transfer&&!!view.options.find(o=>o.id===selected)?.requiresTransfer),locked=busy||uncertain||stale||!!view?.pending;
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setError('');request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted)accept(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,version]);
|
||||
async function close(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Gerätezuordnung verwerfen?'))onClose();}
|
||||
async function reload(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Gerätezuordnung verwerfen und neu laden?')){pending.current=null;setStale(false);setNotice('');setVersion(v=>v+1);}}
|
||||
async function send(retry=false){if(sending.current||stale||!view)return;pending.current??={retry,body:retry?{requestId:crypto.randomUUID(),revision:view.pending?.revision,pending:view.pending?.id}:{requestId:crypto.randomUUID(),revision:view.revision,selected:view.editable?0:selected,phone,transfer}};sending.current=true;setBusy(true);setError('');setNotice('');try{const p=pending.current;const result=p.retry?await post<View>(base+'/retry',p.body):await put<View>(base,p.body);accept(result);pending.current=null;setUncertain(false);setNotice(result.pending?'Die Gerätezuordnung ist vorgemerkt. Der zentrale Abgleich steht noch aus.':'Gerätetelefon gespeichert.');}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setStale(e instanceof ApiError&&e.status===409);}}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title="Gerätezuordnung / VPA-PDA" onClose={()=>void close()}><div className="modal-body admin-dialog-body">{view&&<h3>{view.label}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{busy&&!view&&<p>Gerätezuordnung wird geladen …</p>}{view&&<><p>Aktuell gespeichertes Gerätetelefon: <strong>{view.phone||'Kein Eintrag'}</strong></p>{view.pending?<section><h3>Vorgemerkte Zuordnung</h3><p>{view.pending.phone||'Kein Eintrag'} · {({PENDING:'Abgleich ausstehend',SENDING:'Abgleich läuft',RETRY:'Erneuter Versuch ausstehend',BLOCKED:'Zuordnung muss geprüft werden'} as Record<string,string>)[view.pending.state]||view.pending.state}</p>{view.pending.message&&<p role="status">{view.pending.message}</p>}<p>Die lokale Nummer wird erst nach bestätigter zentraler Zuordnung geändert.</p>{['RETRY','BLOCKED'].includes(view.pending.state)&&<button className="button" disabled={busy||uncertain||stale||!view.enabled} onClick={()=>void send(true)}>Abgleich erneut vormerken</button>}</section>:<fieldset disabled={locked||!view.editable&&(!view.enabled||!!view.optionsError)}><Field label="Mobilnummer VPA/PDA">{view.editable?<input type="tel" maxLength={30} value={phone} onChange={e=>setPhone(e.target.value)}/>:<select value={selected} onChange={e=>{const id=Number(e.target.value);setSelected(id);setTransfer(false);setPhone(view.options.find(o=>o.id===id)?.label||'');}}><option value={0}>{phone&&selected===0?'Bestehende Nummer · zum Entfernen „Zuordnung lösen“ wählen':'Kein Eintrag'}</option>{view.options.map(o=><option key={o.id} value={o.id} disabled={o.available===false}>{o.label}{o.requiresTransfer?` · ${o.assignment}`:""}</option>)}</select>}</Field><button className="button" type="button" disabled={!phone} onClick={()=>{setPhone('');setSelected(0);}}>Zuordnung lösen</button>{view.editable&&phone!==view.phone&&phone&&<label className="checkbox-label"><input type="checkbox" checked={transfer} onChange={e=>setTransfer(e.target.checked)}/>Falls bereits vergeben: bisherige lokale Zuordnung lösen und Nummer übernehmen</label>}{!view.editable&&view.options.find(o=>o.id===selected)?.requiresTransfer&&<label className="checkbox-label"><input type="checkbox" checked={transfer} onChange={e=>setTransfer(e.target.checked)}/>Bisherige lokale Zuordnung bei {view.options.find(o=>o.id===selected)?.assignment} lösen und Nummer übernehmen</label>}<p className="form-hint">{view.editable?'Freie Telefonpflege ist für diese Niederlassung aktiviert.':'Die Auswahl enthält freie oder diesem Fahrer zugeordnete Nummern der aktuellen Niederlassung.'}</p></fieldset>}{view.optionsError&&<p className="alert error" role="alert">{view.optionsError}</p>}{!view.enabled&&!view.editable&&<p>Der zentrale Geräteabgleich ist ausgeschaltet.</p>}</>}{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte denselben Vorgang erneut abrufen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={busy||stale||!view||!uncertain&&(!dirty||!!view.pending||!view.editable&&(!view.enabled||!!view.optionsError||!!view.options.find(o=>o.id===selected)?.requiresTransfer&&!transfer))} onClick={()=>void send(pending.current?.retry)}>{uncertain?'Ergebnis erneut abrufen':'Zuordnung speichern'}</button></div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {timelineWindow,visibleTimelineSegment,timelineGrabOffset,timelineDropSlot,type DisplayRange} from './dispositionTimelineRange.mjs';
|
||||
import {TimelineRangeControls,TimelineHourScale,timelineTrackBackground} from './DispositionTimelineDisplay';
|
||||
import {DispositionDayPicker} from './DispositionDayPicker';
|
||||
import {DispositionCardText,useDispositionCards,cardDescription,cardStyle} from './DispositionJobCard';
|
||||
import {useEffect,useRef,useState,type DragEvent} from 'react';
|
||||
import {Field} from './FormElements';
|
||||
import {request} from './api';
|
||||
import {CourierDispositionJob} from './CourierDispositionJob';
|
||||
import {CourierDispositionRollcard} from './CourierDispositionRollcard';
|
||||
import {CourierDispositionHandover} from './CourierDispositionHandover';
|
||||
import {CourierDispositionPlanning} from './CourierDispositionPlanning';
|
||||
import {CourierDispositionSearch} from './CourierDispositionSearch';
|
||||
import {VehicleMultiDayPlanning,type VehiclePlanningProposal} from './VehicleMultiDayPlanning';
|
||||
|
||||
type Segment={start:number;end:number;from:string;to:string;job:number;accessible:boolean};
|
||||
type Row={id:number;sid:string;registration:string;warning:string;weight:number|null;positions:number|null;blocked:number;locked:boolean;remark:string;segments:Segment[];unreserved:Segment[];jobs:{id:number;status:number;offer:boolean}[]};
|
||||
type View={displayRange?:DisplayRange;confirmDate?:boolean;day:string;today:string;headquarters:number;rows:Row[];total:number;offset:number;limit:number;units:number;defaultScale:number};
|
||||
const status=(n:number)=>({0:'Angeboten',1:'Angenommen',2:'Erledigt',8:'Vorbestellung',9:'Reserviert'}[n]??`Status ${n}`);
|
||||
|
||||
export function CourierDisposition({onClose}:{onClose:()=>void}){
|
||||
const [data,setData]=useState<View|null>(null),[query,setQuery]=useState({day:'',offset:0,vehicle:0}),[refresh,setRefresh]=useState(0),[loading,setLoading]=useState(false),[error,setError]=useState(''),[scale,setScale]=useState<number|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setLoading(true);setError('');setData(null);request<View>(`/courier-portal/disposition?${new URLSearchParams({...query.day?{day:query.day}:{},offset:String(query.offset),...query.vehicle?{vehicle:String(query.vehicle)}:{}})}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setData(v);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,refresh]);
|
||||
const cards=useDispositionCards(data?`/courier-portal/disposition/cards?${new URLSearchParams({day:data.day,offset:String(query.offset),...query.vehicle?{vehicle:String(query.vehicle)}:{}})}`:null,data);
|
||||
const [selected,setSelected]=useState<{vehicle:number;day:string;job:number}|null>(null),[notice,setNotice]=useState('');
|
||||
const [rollcard,setRollcard]=useState<{vehicle:number;day:string}|null>(null);const [handover,setHandover]=useState<{vehicle:number;day:string}|null>(null);const [planning,setPlanning]=useState<{vehicle:number;day:string}|null>(null);const [search,setSearch]=useState(false),[highlight,setHighlight]=useState<{job:number;day:string}|null>(null);const [replanning,setReplanning]=useState<{vehicle:number;day:string;job:number;proposal?:VehiclePlanningProposal}|null>(null);const dialogOpen=!!selected||!!rollcard||!!handover||!!planning||search||!!replanning;
|
||||
const drag=useRef<{vehicle:number;job:number;grab:number}|null>(null),[hover,setHover]=useState<{vehicle:number;slot:number}|null>(null);
|
||||
const shownScale=scale??data?.defaultScale??2;
|
||||
const [fullDay,setFullDay]=useState(false),display=timelineWindow(data?.displayRange,fullDay);
|
||||
const canMove=(row:Row,job:number)=>!!data&&!dialogOpen&&data.day>=data.today&&!row.locked&&!(row.blocked&1)&&!row.warning&&row.jobs.some(j=>j.id===job&&[0,9].includes(j.status)&&!j.offer)&&row.segments.some(s=>s.job===job);
|
||||
const slotTime=(slot:number)=>`${String(Math.floor(slot/data!.units)).padStart(2,'0')}:${String(slot%data!.units*60/data!.units).padStart(2,'0')}`;
|
||||
function dragStart(e:DragEvent<HTMLButtonElement>,row:Row,s:Segment){if(!canMove(row,s.job)){e.preventDefault();return;}const box=e.currentTarget.getBoundingClientRect();drag.current={vehicle:row.id,job:s.job,grab:timelineGrabOffset(s,display,data!.units,(e.clientX-box.left)/box.width)};e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',`Auftrag #${s.job}`);}
|
||||
function destination(e:DragEvent<HTMLDivElement>,row:Row){if(!data||!drag.current||dialogOpen||row.locked||(row.blocked&1)||row.warning||data.day<data.today)return null;const box=e.currentTarget.getBoundingClientRect();return box.width>0?timelineDropSlot(display,data.units,(e.clientX-box.left)/box.width,drag.current.grab):null;}
|
||||
function dragOver(e:DragEvent<HTMLDivElement>,row:Row){const slot=destination(e,row);if(slot===null){setHover(null);return;}e.preventDefault();e.dataTransfer.dropEffect='move';setHover({vehicle:row.id,slot});}
|
||||
function drop(e:DragEvent<HTMLDivElement>,row:Row){e.preventDefault();const at=destination(e,row),source=drag.current;drag.current=null;setHover(null);if(at!==null&&source&&data){setNotice('');setReplanning({vehicle:source.vehicle,day:data.day,job:source.job,proposal:{target:row.id,startDay:data.day,time:slotTime(at)}});}}
|
||||
function endDrag(){drag.current=null;setHover(null);}
|
||||
return <main className="warehouse-portal courier-portal" style={{maxWidth:1500,margin:'0 auto',padding:24}}><header className="admin-title"><div><h1>Fahrzeugbelegung</h1><p>Die für Sie freigegebenen Fahrzeugtage Ihrer Unterfahrer.</p></div><div className="admin-toolbar"><button className="button" disabled={dialogOpen} onClick={()=>setSearch(true)}>Auftrag suchen</button><button className="button" disabled={loading||dialogOpen} onClick={()=>setRefresh(n=>n+1)}>Aktualisieren</button><button className="button" disabled={dialogOpen} onClick={onClose}>Zur Auftragsübersicht</button></div></header>
|
||||
<section className="panel" style={{padding:20}}><div className="admin-toolbar"><DispositionDayPicker value={query.day||data?.day||''} today={data?.today} confirmDate={data?.confirmDate} disabled={dialogOpen||loading} onChange={day=>setQuery({...query,day,offset:0,vehicle:0})}/>{data&&<Field label="Vergrößerung der Zeitleiste"><select disabled={dialogOpen} value={shownScale} onChange={e=>setScale(Number(e.target.value))}>{Array.from({length:9},(_,i)=><option key={i+1} value={i+1}>{i+1}×</option>)}</select></Field>}</div>
|
||||
{query.vehicle>0&&<p className="form-hint">Fahrzeug aus Auftragssuche ausgewählt. <button className="button" disabled={dialogOpen||loading} onClick={()=>setQuery({...query,vehicle:0,offset:0})}>Alle freigegebenen Fahrzeuge anzeigen</button></p>}{notice&&<p role="status" className="admin-success">{notice}</p>}{error&&<p role="alert" className="alert error">{error}</p>}{loading&&<p>Fahrzeugbelegung wird geladen …</p>}
|
||||
{cards.error&&<p role="alert" className="alert error">Auftragsinformationen: {cards.error}</p>}{data&&!cards.denied&&<><p className="form-hint">{data.day} · {query.vehicle>0?`Ausgewähltes Fahrzeug (${data.total} Fahrzeuge insgesamt)`:`${data.total} Fahrzeuge · Seite ${Math.floor(data.offset/data.limit)+1} / ${Math.max(1,Math.ceil(data.total/data.limit))}`}. Nicht freigegebene Aufträge erscheinen ausschließlich als belegte Zeit. Lieferung, Montage, Angebot und Service sind an den Auftragskarten erkennbar. Aufträge ohne Rasterreservierung tragen einen zusätzlichen Rahmen.</p>
|
||||
<p className="form-hint">Offene Reservierungen auf eine neue Uhrzeit oder ein anderes Fahrzeug ziehen. Anschließend alle betroffenen Tage prüfen und bestätigen. Alternativ „Umplanen“ in der Auftragszeile verwenden. Mehrtägige Aufträge werden am ersten Reservierungstag verschoben.</p><TimelineRangeControls range={data.displayRange} fullDay={fullDay} disabled={dialogOpen} onChange={v=>{endDrag();setFullDay(v);}}/><div className="fds-timeline-scroll"><div className="fds-timeline-grid" style={{minWidth:155+display.hours*66*shownScale}}><TimelineHourScale display={display} hidden={cards.hideHours}/>
|
||||
{data.rows.map(row=><div className="fds-timeline-row" key={row.id}><div className="fds-timeline-label"><strong>{row.sid||`#${row.id}`}</strong><small>{row.registration}</small><small>{row.weight===null?'Keine Kapazität':`${row.weight.toLocaleString('de-DE')} kg frei`}</small>{row.warning&&<small className="fds-timeline-warning">{row.warning}</small>}</div><div className="fds-timeline-track fds-timeline-cards" style={{minHeight:row.unreserved.length?340:174,backgroundImage:timelineTrackBackground(display)}} aria-label={`Zeitleiste ${row.sid||row.id}`} onDragOver={e=>dragOver(e,row)} onDrop={e=>drop(e,row)} onDragLeave={()=>setHover(h=>h?.vehicle===row.id?null:h)}>{[...row.segments,...row.unreserved].map((segment,i)=>{const visible=visibleTimelineSegment(segment,display,data.units);if(!visible)return null;const actual=i>=row.segments.length;const label=`${segment.from}–${segment.to} · ${segment.job>0?`Auftrag #${segment.job}${actual?' ohne Rasterreservierung':''}`:segment.job<0?'Nicht freigegebene Belegung':'Unbelegt'}`;const style={left:`${visible.left}%`,width:`${visible.width}%`,...cardStyle(cards.cards[segment.job]),...actual?{top:178,border:'2px dashed currentColor'}:{},cursor:segment.job>0?'pointer':'default',...highlight?.day===data.day&&highlight.job===segment.job?{outline:'3px solid #b42318',outlineOffset:2}:{}};return segment.job>0?<button key={i} draggable={!actual&&canMove(row,segment.job)} onDragStart={e=>dragStart(e,row,segment)} onDragEnd={endDrag} disabled={dialogOpen} className="fds-timeline-reservation" style={style} title={[label,cardDescription(cards.cards[segment.job])].filter(Boolean).join("\n")} aria-label={[label,cardDescription(cards.cards[segment.job])].filter(Boolean).join(" · ")} onClick={()=>{setNotice('');setSelected({vehicle:row.id,day:data.day,job:segment.job});}}><DispositionCardText job={segment.job} card={cards.cards[segment.job]}/></button>:<span key={i} className={segment.job===0?'fds-timeline-free':'fds-timeline-unknown'} style={style} title={[label,cardDescription(cards.cards[segment.job])].filter(Boolean).join("\n")} aria-label={label}/>;})}{hover?.vehicle===row.id&&<span className="fds-timeline-drop" style={{left:`${100*(hover.slot-display.start*data.units)/(display.hours*data.units)}%`}}><span>{slotTime(hover.slot)}</span></span>}</div></div>)}</div></div>
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Fahrzeug</th><th>Tagesfreigabe</th><th>Restkapazität</th><th>Aufträge</th><th>Tagesbemerkung</th></tr></thead><tbody>{data.rows.map(row=><tr key={row.id}><td><strong>{row.sid||`#${row.id}`}</strong><br/>{row.registration}<p><button className="button" disabled={dialogOpen} onClick={()=>{setNotice('');setRollcard({vehicle:row.id,day:data.day});}}>Rollkarte · {row.sid||row.id}</button> <button className="button" disabled={dialogOpen} onClick={()=>{setNotice('');setHandover({vehicle:row.id,day:data.day});}}>Endgerät · {row.sid||row.id}</button> <button className="button" disabled={dialogOpen} onClick={()=>{setNotice('');setPlanning({vehicle:row.id,day:data.day});}}>Reihenfolge · {row.sid||row.id}</button></p></td><td>{row.blocked&1?'Tag gesperrt':'Tag freigegeben'}<br/>{row.blocked&2?'Unternehmerdisposition':'Zentralendisposition'}{row.locked&&<p>Verarbeitung läuft</p>}{row.warning&&<p className="alert info">{row.warning}</p>}</td><td>{row.weight===null?'–':`${row.weight.toLocaleString('de-DE')} kg · ${row.positions} Stellplätze`}</td><td>{row.jobs.length?row.jobs.map(job=><p key={job.id} style={highlight?.day===data.day&&highlight.job===job.id?{background:'#fff1e8',borderLeft:'4px solid #b42318',padding:6}:{}}><button className="button" disabled={dialogOpen} onClick={()=>{setNotice('');setSelected({vehicle:row.id,day:data.day,job:job.id});}}>#{job.id}</button> {canMove(row,job.id)&&<button className="button" onClick={()=>{setNotice('');setReplanning({vehicle:row.id,day:data.day,job:job.id});}}>Umplanen · #{job.id}</button>} · {status(job.status)}{row.unreserved.some(s=>s.job===job.id)?' · ohne Rasterreservierung':''}{job.offer?' · Angebot':''}</p>):'Keine freigegebenen Aufträge'}</td><td style={{whiteSpace:'pre-wrap'}}>{row.remark||'–'}</td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine Fahrzeuge in dieser Auswahl.</p>}{!query.vehicle&&<div className="admin-toolbar"><button className="button" disabled={dialogOpen||data.offset===0} onClick={()=>setQuery({...query,offset:Math.max(0,data.offset-data.limit)})}>Vorherige Seite</button><button className="button" disabled={dialogOpen||data.offset+data.limit>=data.total} onClick={()=>setQuery({...query,offset:data.offset+data.limit})}>Nächste Seite</button></div>}</>}
|
||||
</section>{replanning&&<VehicleMultiDayPlanning {...replanning} audience="COURIER" headquarters={0} headquartersLabel="" onClose={()=>{setReplanning(null);setRefresh(n=>n+1);}}/>}{search&&<CourierDispositionSearch onClose={()=>setSearch(false)} onSelect={(vehicle,day,job)=>{setSearch(false);setHighlight({job,day});setQuery({vehicle,day,offset:0});setNotice(`Suchtreffer: Auftrag #${job} am ${day}.`);setRefresh(n=>n+1);}}/>}{selected&&<CourierDispositionJob card={cards.cards[selected.job]} {...selected} onClose={()=>setSelected(null)} onSaved={message=>{setSelected(null);setNotice(message||'Stationsbemerkung gespeichert.');setRefresh(n=>n+1);}}/>}{rollcard&&<CourierDispositionRollcard {...rollcard} onClose={()=>setRollcard(null)}/>}{handover&&<CourierDispositionHandover {...handover} onClose={changed=>{setHandover(null);if(changed){setNotice('Rollkartenübergabe gespeichert.');setRefresh(n=>n+1);}}}/>}{planning&&<CourierDispositionPlanning {...planning} onClose={changed=>{setPlanning(null);if(changed){setNotice('Reihenfolge und Zeiten gespeichert.');setRefresh(n=>n+1);}}}/>}</main>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type View={action:'SEND'|'RECALL'|'ROLLCARD';count:number;target:number;targetSid:string;jobs:{id:number;beforeStatus:number;afterStatus:number;beforeCourier:number}[];blockers:string[];warnings:string[];revision:string;planRevision?:string};
|
||||
type Command={requestId:string;vehicle:number;day:string;job:number;action:string;revision:string;planRevision:string;confirmed:boolean};
|
||||
const status=(n:number)=>({0:'Offen / angeboten',1:'Angenommen',2:'Erledigt',9:'Reserviert'}[n]??`Status ${n}`);
|
||||
export function CourierDispositionHandover({vehicle,day,job=0,onClose}:{vehicle:number;day:string;job?:number;onClose:(changed:boolean)=>void}){
|
||||
const base='/courier-portal/disposition/handover',url=`${base}?${new URLSearchParams({vehicle:String(vehicle),day,job:String(job)})}`;
|
||||
const [data,setData]=useState<View|null>(null),[preview,setPreview]=useState<View|null>(null),[busy,setBusy]=useState(true),[error,setError]=useState(''),[ack,setAck]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);const command=useRef<Command|null>(null),sending=useRef(false);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setData(null);setPreview(null);setAck(false);setError('');setStale(false);command.current=null;request<View>(url,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[url,reload]);
|
||||
async function prepare(){if(!data||busy||sending.current||uncertain||stale||done)return;sending.current=true;setBusy(true);setError('');setPreview(null);setAck(false);command.current=null;try{const c:Command={requestId:crypto.randomUUID(),vehicle,day,job,action:data.action,revision:data.revision,planRevision:'',confirmed:false};const p=await post<View>(base+'/preview',c);command.current={...c,planRevision:p.planRevision!,confirmed:true};setPreview(p);}catch(e){setError((e as Error).message);if(e instanceof ApiError&&e.status===409)setStale(true);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!command.current||busy||sending.current||!ack||stale||done)return;sending.current=true;setBusy(true);setError('');try{await post(base,command.current);setDone(true);setUncertain(false);command.current=null;}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{sending.current=false;setBusy(false);}}
|
||||
function close(){if(!busy&&!uncertain)onClose(done);}
|
||||
const shown=preview||data;
|
||||
return <Modal title={`${data?.action==='RECALL'?'Auftrag zurückholen':job?'Auftrag übergeben':'Rollkarte aufs Endgerät'} · ${job?'#'+job:'Fahrzeug #'+vehicle}`} wide onClose={close}><div className="admin-dialog-body modal-body"><p>{day} · Fahrzeug #{vehicle}</p>{error&&<p role="alert" className="alert error">{error}</p>}{busy&&!data&&<p>Freigaben und Auftragsstand werden geprüft …</p>}{done&&<p role="status" className="admin-success">{data?.action==='RECALL'?'Rückholung gespeichert.':'Übergabe gespeichert.'} Die Fahrzeugübersicht wird nach dem Schließen aktualisiert.</p>}{shown&&<>{shown.blockers.length>0&&<section className="alert error" aria-label="Aktion nicht möglich"><strong>Aktion nicht möglich</strong><ul>{shown.blockers.map((b,i)=><li key={i}>{b}</li>)}</ul></section>}<p>{shown.count} Aufträge · neue Kurierzuordnung: {shown.target?`${shown.targetSid} · #${shown.target}`:'Keine Zuordnung'}</p><table className="admin-table"><thead><tr><th>Auftrag</th><th>Bisheriger Status</th><th>Neuer Status</th></tr></thead><tbody>{shown.jobs.map(j=><tr key={j.id}><td>#{j.id}</td><td>{status(j.beforeStatus)}</td><td>{status(j.afterStatus)}</td></tr>)}</tbody></table>{shown.warnings.map((w,i)=><p className="form-hint" key={i}>{w}</p>)}{preview&&!done&&<label className="admin-check"><input type="checkbox" disabled={busy||uncertain||stale} checked={ack} onChange={e=>setAck(e.target.checked)}/>Zuordnung, Status und Auswirkungen geprüft</label>}</>}{uncertain&&<p role="status" className="alert info">Die Antwort ist ausgeblieben. Erneutes Abrufen verwendet dieselbe Vorgangskennung und ändert den Auftrag nicht doppelt.</p>}{stale&&<p>Bitte den aktuellen Stand laden und erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={close}>Schließen</button>{!done&&<><button className="button" disabled={busy||uncertain} onClick={()=>setReload(n=>n+1)}>Aktuellen Stand laden</button>{!preview?<button className="button primary" disabled={!data||!!data.blockers.length||busy||stale} onClick={()=>void prepare()}>Änderung prüfen</button>:<button className="button primary" disabled={busy||stale||!ack} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':data?.action==='RECALL'?'Auftrag zurückholen':'Übergabe speichern'}</button>}</>}</div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {type DispositionCard} from './DispositionJobCard';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
import {CourierDispositionHandover} from './CourierDispositionHandover';
|
||||
|
||||
type View={vehicle:number;job:number;day:string;status:number;destination:{company:string;person:string;phone:string};remark:string;canRemark:boolean;revision:string;columns:{label:string;type:string}[];articles:{id:number;cells:string[]}[];weight:number;pieces:number;goodsValue:number;additional:string};
|
||||
type Command={requestId:string;vehicle:number;day:string;job:number;revision:string;value:string};
|
||||
const number=(value:number)=>value.toLocaleString('de-DE',{maximumFractionDigits:2});
|
||||
const money=(value:number)=>value.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
export function CourierDispositionJob({vehicle,day,job,card,onClose,onSaved}:{vehicle:number;day:string;job:number;card?:DispositionCard;onClose:()=>void;onSaved:(notice?:string)=>void}){
|
||||
const [data,setData]=useState<View|null>(null),[value,setValue]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState(''),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[refresh,setRefresh]=useState(0);
|
||||
const [handover,setHandover]=useState(false);
|
||||
const pending=useRef<Command|null>(null),sending=useRef(false);const [discard,dialog]=useDiscardConfirmation();const dirty=!!data&&value!==data.remark;
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setError('');setData(null);request<View>(`/courier-portal/disposition/job?${new URLSearchParams({vehicle:String(vehicle),day,job:String(job)})}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setData(v);setValue(v.remark);setStale(false);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[vehicle,day,job,refresh]);
|
||||
async function close(){if(busy||uncertain||handover)return;if(!dirty||await discard('Ungespeicherte Stationsbemerkung verwerfen?'))onClose();}
|
||||
async function reload(){if(busy||uncertain||handover||dirty&&!await discard('Ungespeicherte Stationsbemerkung verwerfen?'))return;pending.current=null;setRefresh(n=>n+1);}
|
||||
async function save(){if(!data||busy||handover||sending.current||stale||!data.canRemark)return;if(!pending.current)pending.current={requestId:crypto.randomUUID(),vehicle,day,job,revision:data.revision,value};sending.current=true;setBusy(true);setError('');try{await post('/courier-portal/disposition/job/remark',pending.current);pending.current=null;setUncertain(false);onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setStale(true);}}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal wide title={`Auftrag #${job} · Artikel und Zielbemerkung`} onClose={()=>void close()}><div className="admin-dialog-body">{error&&<p role="alert" className="alert error">{error}</p>}{busy&&!data&&<p>Auftrag wird geladen …</p>}{data&&<><p><strong>{data.destination.company}</strong><br/>{data.destination.person}{data.destination.phone&&` · ${data.destination.phone}`}</p><h3>Artikel an Station 2</h3>{data.articles.length?<><div className="admin-table-wrap"><table className="admin-table"><thead><tr>{data.columns.map((c,i)=><th key={i}>{c.label}</th>)}</tr></thead><tbody>{data.articles.map(a=><tr key={a.id}>{a.cells.map((v,i)=><td key={i}>{data.columns[i].type==='money'?money(Number(v)):data.columns[i].type==='decimal'?number(Number(v)):v}</td>)}</tr>)}</tbody></table></div><p>{number(data.pieces)} Packstücke · {number(data.weight)} kg · Warenwert {money(data.goodsValue)}</p>{data.additional&&<p style={{whiteSpace:'pre-wrap'}}>{data.additional}</p>}</>:<p>Keine Artikel an Station 2.</p>}{card&&<section className="fds-card-detail" aria-label="Zieladresse und Zeitfenster"><h3>Zieladresse und Zeitfenster</h3>{card.details.map((line,i)=><p key={i}>{line}</p>)}</section>}<Field label="Bemerkung der zweiten Station"><textarea rows={6} maxLength={65535} value={value} readOnly={!data.canRemark} disabled={handover||busy||uncertain||stale} onChange={e=>setValue(e.target.value)}/></Field>{!data.canRemark&&<p className="form-hint">Die Bemerkung dieses Auftrags ist schreibgeschützt.</p>}</>}{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte denselben Vorgang erneut abrufen.</p>}</div><div className="modal-footer"><button className="button" disabled={handover||busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button" disabled={handover||busy||uncertain} onClick={()=>void reload()}>Neu laden</button>{data&&[0,1,9].includes(data.status)&&<button className="button" disabled={handover||busy||uncertain||stale||dirty} onClick={()=>setHandover(true)}>{data.status===1?'Auftrag zurückholen':'Übergabe prüfen'}</button>}{data?.canRemark&&<button className="button primary" disabled={handover||busy||stale||!dirty&&!uncertain} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Bemerkung speichern'}</button>}</div></Modal>{dialog}{handover&&<CourierDispositionHandover vehicle={vehicle} day={day} job={job} onClose={changed=>{setHandover(false);if(changed)onSaved(data?.status===1?'Rückholung gespeichert.':'Übergabe gespeichert.');}}/>}</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type Row={id:number;status:number;time:string;from:string;duration:number;slots:number};
|
||||
type View={vehicle:number;sid:string;day:string;step:number;rows:Row[];blockers:string[];revision:string};
|
||||
type Preview={planRevision:string;changes:{id:number;before:string;after:string;from:string;to:string;slots:number;duration:number}[];warnings:string[]};
|
||||
type Command={requestId:string;vehicle:number;day:string;entries:{job:number;time:string}[];revision:string;planRevision:string;confirmed:boolean};
|
||||
export function CourierDispositionPlanning({vehicle,day,onClose}:{vehicle:number;day:string;onClose:(changed:boolean)=>void}){
|
||||
const base='/courier-portal/disposition/planning',url=`${base}?${new URLSearchParams({vehicle:String(vehicle),day})}`;
|
||||
const [data,setData]=useState<View|null>(null),[rows,setRows]=useState<Row[]>([]),[preview,setPreview]=useState<Preview|null>(null),[busy,setBusy]=useState(true),[error,setError]=useState(''),[ack,setAck]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0),[discard,setDiscard]=useState(false);
|
||||
const command=useRef<Command|null>(null),sending=useRef(false);const dirty=!!data&&JSON.stringify(rows.map(r=>[r.id,r.time]))!==JSON.stringify(data.rows.map(r=>[r.id,r.time]));
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setData(null);setRows([]);setPreview(null);setAck(false);setError('');setStale(false);setDiscard(false);command.current=null;request<View>(url,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setData(v);setRows(v.rows);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[url,reload]);
|
||||
const frozen=busy||uncertain||stale||done;
|
||||
function edit(next:Row[]){if(frozen)return;setRows(next);setPreview(null);setAck(false);setError('');command.current=null;}
|
||||
function move(index:number,delta:number){const next=[...rows];[next[index],next[index+delta]]=[next[index+delta],next[index]];edit(next);}
|
||||
async function prepare(){if(!data||frozen||sending.current)return;sending.current=true;setBusy(true);setError('');setPreview(null);setAck(false);command.current=null;try{const c:Command={requestId:crypto.randomUUID(),vehicle,day,entries:rows.map(r=>({job:r.id,time:r.time})),revision:data.revision,planRevision:'',confirmed:false};const p=await post<Preview>(base+'/preview',c);command.current={...c,planRevision:p.planRevision,confirmed:true};setPreview(p);}catch(e){setError((e as Error).message);if(e instanceof ApiError&&[403,409].includes(e.status))setStale(true);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!command.current||busy||sending.current||!ack||stale||done)return;sending.current=true;setBusy(true);setError('');try{await post(base,command.current);setDone(true);setUncertain(false);command.current=null;}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{sending.current=false;setBusy(false);}}
|
||||
function close(){if(busy||uncertain)return;if(dirty&&!done){setDiscard(true);return;}onClose(done);}
|
||||
return <Modal title={`Reihenfolge und Zeiten · ${data?.sid||'Fahrzeug #'+vehicle}`} wide onClose={close}><div className="admin-dialog-body modal-body"><p>{day} · Die Uhrzeiten müssen in der gewählten Reihenfolge aufsteigen. Eingaben werden auf das {data?.step??'…'}-Minuten-Raster aufgerundet.</p>{error&&<p role="alert" className="alert error">{error}</p>}{busy&&!data&&<p>Fahrzeugtag und Reservierungen werden geprüft …</p>}{data?.blockers.length? <section className="alert error" aria-label="Umsortierung nicht möglich"><strong>Umsortierung nicht möglich</strong><ul>{data.blockers.map((b,i)=><li key={i}>{b}</li>)}</ul></section>:null}{done&&<p role="status" className="admin-success">Reihenfolge und Zeiten gespeichert. Die Fahrzeugübersicht wird nach dem Schließen aktualisiert.</p>}
|
||||
{data&&<table className="admin-table"><thead><tr><th>Reihenfolge</th><th>Auftrag</th><th>Neue Startzeit</th><th>Bisher reserviert</th><th>Dauer</th></tr></thead><tbody>{rows.map((r,i)=><tr key={r.id}><td><button className="button" aria-label={`Auftrag ${r.id} nach oben`} disabled={frozen||i===0||!!data.blockers.length} onClick={()=>move(i,-1)}>↑</button> <button className="button" aria-label={`Auftrag ${r.id} nach unten`} disabled={frozen||i===rows.length-1||!!data.blockers.length} onClick={()=>move(i,1)}>↓</button></td><td>#{r.id}</td><td><input type="time" step="60" aria-label={`Startzeit Auftrag ${r.id}`} disabled={frozen||!!data.blockers.length} value={r.time} onChange={e=>edit(rows.map(row=>row.id===r.id?{...row,time:e.target.value}:row))}/></td><td>{r.from} · {r.slots*data.step} Min.</td><td>{r.duration*data.step} Min.</td></tr>)}</tbody></table>}
|
||||
{preview&&<section aria-label="Planungsvorschau"><h3>Geprüfte Belegung</h3><table className="admin-table"><thead><tr><th>Auftrag</th><th>Bisheriger Plantermin</th><th>Neue Reservierung</th><th>Reserviert / Auftragsdauer</th></tr></thead><tbody>{preview.changes.map(c=><tr key={c.id}><td>#{c.id}</td><td>{c.before}</td><td>{c.from}–{c.to}</td><td>{c.slots*(data?.step||0)} / {c.duration*(data?.step||0)} Min.</td></tr>)}</tbody></table>{preview.warnings.map((w,i)=><p key={i} className="form-hint">{w}</p>)}{!done&&<label className="admin-check"><input type="checkbox" checked={ack} disabled={frozen} onChange={e=>setAck(e.target.checked)}/>Reihenfolge, Zeiten und Auswirkungen geprüft</label>}</section>}{uncertain&&<p role="status" className="alert info">Die Antwort ist ausgeblieben. Erneutes Abrufen verwendet dieselbe Vorgangskennung und speichert nicht doppelt.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Planung erneut prüfen.</p>}
|
||||
{discard&&<section role="alert" className="alert info"><p>Ungespeicherte Planung verwerfen?</p><button className="button" onClick={()=>setDiscard(false)}>Weiter bearbeiten</button> <button className="button" onClick={()=>onClose(false)}>Planung verwerfen</button></section>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={close}>Schließen</button>{!done&&<><button className="button" disabled={busy||uncertain||dirty&&!stale} onClick={()=>setReload(n=>n+1)}>Aktuellen Stand laden</button><button className="button" disabled={!data||!!data.blockers.length||frozen} onClick={()=>void prepare()}>Planung prüfen</button>{preview&&<button className="button primary" disabled={busy||stale||!ack} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Reihenfolge und Zeiten speichern'}</button>}</>}</div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {VehicleRollcardMail} from './VehicleRollcardMail';
|
||||
import {ApiError,downloadBusinessFile,request} from './api';
|
||||
type Rollcard={headquarters:number;sid:string;day:string;loadedAt:string;cards:{id:number;customer:number;customerLabel:string}[];warnings:string[];html:string;revision:string};
|
||||
export function CourierDispositionRollcard({vehicle,day,onClose}:{vehicle:number;day:string;onClose:()=>void}){
|
||||
const [selection,setSelection]=useState('ALL'),[reload,setReload]=useState(0),[data,setData]=useState<Rollcard|null>(null),[error,setError]=useState(''),[ready,setReady]=useState(false),[loading,setLoading]=useState(true),[downloading,setDownloading]=useState(false);const frame=useRef<HTMLIFrameElement>(null);
|
||||
const [mail,setMail]=useState(false);const locked=downloading||mail;
|
||||
const path='/courier-portal/disposition/rollcard';
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');setReady(false);setLoading(true);request<Rollcard>(`${path}?${new URLSearchParams({vehicle:String(vehicle),day,selection})}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[vehicle,day,selection,reload]);
|
||||
async function pdf(){if(!data||downloading)return;setDownloading(true);setError('');try{await downloadBusinessFile(`${path}/pdf?${new URLSearchParams({vehicle:String(vehicle),day,selection,revision:data.revision})}`,undefined,`Rollkarte-${vehicle}-${day}.pdf`,'GET');}catch(e){setError((e as Error).message);if(e instanceof ApiError&&[403,409].includes(e.status))setData(null);}finally{setDownloading(false);}}
|
||||
function html(){if(!data)return;const url=URL.createObjectURL(new Blob([data.html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download=`Rollkarte-${vehicle}-${day}.html`;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
return <><Modal title={`Rollkarte · ${data?.sid||vehicle} · ${day}`} wide onClose={()=>{if(!locked)onClose();}}><div className="admin-dialog-body modal-body vehicle-rollcard"><div className="admin-toolbar"><Field label="Aufträge auf der Rollkarte"><select disabled={locked} value={selection} onChange={e=>setSelection(e.target.value)}><option value="ALL">Reservierte und angenommene Aufträge</option><option value="RESERVED">Nur reservierte Aufträge</option><option value="ACCEPTED">Nur angenommene Aufträge</option></select></Field><button className="button" disabled={loading||locked} onClick={()=>setReload(n=>n+1)}>Aktuellen Stand laden</button></div>{error&&<p role="alert" className="alert error">{error}</p>}{loading&&<p>Rollkarte wird geladen …</p>}{data&&<><p role="status">{data.cards.length} Aufträge · Stand: {data.loadedAt}</p>{data.warnings.map((w,i)=><p key={i} className="alert info">{w}</p>)}<iframe ref={frame} title="Rollkarte – Druckvorschau" className="newsletter-preview" sandbox="allow-same-origin allow-modals" srcDoc={data.html} onLoad={()=>setReady(true)}/></>}</div><div className="modal-footer"><button className="button" disabled={locked} onClick={onClose}>Schließen</button><button className="button" disabled={!data?.cards.length||loading||locked} onClick={html}>HTML herunterladen</button><button className="button" disabled={!data?.cards.length||loading||locked} onClick={()=>setMail(true)}>Per E-Mail senden</button><button className="button primary" disabled={!data?.cards.length||loading||locked} onClick={()=>void pdf()}>PDF herunterladen</button><button className="button" disabled={!data?.cards.length||!ready||loading||locked} onClick={()=>{try{frame.current?.contentWindow?.print();}catch{setError('Bitte die HTML-Datei herunterladen und im Browser drucken.');}}}>Drucken</button></div></Modal>{mail&&data&&<VehicleRollcardMail audience="COURIER" vehicle={vehicle} day={day} selection={selection} headquarters={data.headquarters} headquartersLabel="Transporteurportal" revision={data.revision} customers={Array.from(new Map(data.cards.map(c=>[c.customer,{id:c.customer,label:c.customerLabel}])).values())} onClose={()=>setMail(false)}/>}</>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
type Row={id:number;status:number;ordertime:string;vehicle:number;sid:string;day:string};
|
||||
type Result={q:string;related:boolean;rows:Row[];offset:number;hasMore:boolean};
|
||||
const status=(n:number)=>({0:'Angeboten',1:'Angenommen',2:'Erledigt',8:'Vorbestellung',9:'Reserviert'}[n]??`Status ${n}`);
|
||||
export function CourierDispositionSearch({onSelect,onClose}:{onSelect:(vehicle:number,day:string,job:number)=>void;onClose:()=>void}){
|
||||
const [input,setInput]=useState(''),[query,setQuery]=useState(''),[offset,setOffset]=useState(0),[reload,setReload]=useState(0),[data,setData]=useState<Result|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{setData(null);setError('');if(!query)return;const c=new AbortController();setBusy(true);request<Result>('/courier-portal/disposition/job-search?'+new URLSearchParams({q:query,offset:String(offset)}),{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[query,offset,reload]);
|
||||
return <Modal title="Auftrag in der Fahrzeugbelegung suchen" wide onClose={onClose}><div className="modal-body admin-dialog-body"><p>Exakte Auftragsnummer, Kommissionsnummer oder zusätzliche Suchkennung. Gesucht wird über alle für Sie freigegebenen Fahrzeugtage.</p><form className="admin-toolbar" onSubmit={e=>{e.preventDefault();setOffset(0);setQuery(input.trim());setReload(n=>n+1);}}><Field label="Auftragsnummer oder Suchkennung"><input required maxLength={100} value={input} onChange={e=>setInput(e.target.value)}/></Field><button className="button primary" disabled={busy||!input.trim()}>Suchen</button></form>
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p>Freigegebene Aufträge und Fahrzeugtage werden gesucht …</p>}{data&&<><p className="form-hint">{data.related?'Mit /1 suchen Sie den Grundauftrag, mit /2 seine Folgeaufträge.':'Classic: Eine rein numerische Eingabe sucht ausschließlich die interne Auftragsnummer.'}</p><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Auftrag</th><th>Plantermin / Status</th><th>Fahrzeugtag</th><th>Aktion</th></tr></thead><tbody>{data.rows.map(r=><tr key={`${r.id}/${r.vehicle}/${r.day}`}><td><strong>#{r.id}</strong></td><td>{r.ordertime||'Ohne Plantermin'}<br/>{status(r.status)}</td><td>{r.day} · {r.sid}</td><td><button className="button" onClick={()=>onSelect(r.vehicle,r.day,r.id)}>Fahrzeugtag öffnen · #{r.id} · {r.day}</button></td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine passenden freigegebenen Aufträge.</p>}<div className="admin-toolbar"><button className="button" disabled={busy||!offset} onClick={()=>setOffset(n=>n-50)}>Vorherige Treffer</button><span>Seite {1+offset/50}</span><button className="button" disabled={busy||!data.hasMore} onClick={()=>setOffset(n=>n+50)}>Weitere Treffer</button></div></>}</div><div className="modal-footer"><button className="button" onClick={onClose}>Schließen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {CourierAvailability} from './CourierAvailability';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,put,request} from './api';
|
||||
|
||||
type Values={nationality:string;birthdate:string|null;parent:number;appBlocked:boolean};
|
||||
type View={headquarters:{id:number;label:string}[];currentHeadquarters:number;id:number;label:string;values:Values;revision:string;countries:{code:string;label:string}[];parentLabel:string;lastLogout:{requestedAt:string;validUntil:string}|null};
|
||||
type Parents={items:{id:number;label:string}[];truncated:boolean};
|
||||
export function CourierDriverProfile({courier,onClose}:{courier:number;onClose:()=>void}){
|
||||
const base=`/master-data/couriers/${courier}/driver-profile`;
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values>({nationality:'',birthdate:null,parent:0,appBlocked:false}),[parentLabel,setParentLabel]=useState(''),[query,setQuery]=useState(''),[searchHq,setSearchHq]=useState(0),[parents,setParents]=useState<Parents|null>(null),[searching,setSearching]=useState(false),[searchError,setSearchError]=useState('');
|
||||
const [busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[version,setVersion]=useState(0);
|
||||
const searchToken=useRef(0),sending=useRef(false),pending=useRef<{kind:'save'|'logout';body:unknown}|null>(null),alive=useRef(true),[discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!view&&JSON.stringify(values)!==JSON.stringify(view.values),locked=busy||uncertain||stale;
|
||||
function accept(v:View){searchToken.current++;setSearching(false);setView(v);setSearchHq(v.currentHeadquarters);setValues(v.values);setParentLabel(v.parentLabel);setParents(null);setQuery('');setSearchError('');}
|
||||
useEffect(()=>{alive.current=true;const c=new AbortController();setBusy(true);setView(null);setError('');request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted)accept(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>{alive.current=false;c.abort();};},[base,version]);
|
||||
async function close(){if(sending.current||busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Änderungen am Fahrerprofil verwerfen?'))onClose();}
|
||||
async function reload(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Änderungen verwerfen und neu laden?')){pending.current=null;setStale(false);setNotice('');setVersion(v=>v+1);}}
|
||||
async function search(){if(searching||locked)return;const ticket=++searchToken.current;setSearching(true);setParents(null);setSearchError('');try{const result=await request<Parents>(`${base}/parents?q=${encodeURIComponent(query)}&headquarters=${searchHq}`);if(alive.current&&ticket===searchToken.current)setParents(result);}catch(e){if(alive.current&&ticket===searchToken.current)setSearchError((e as Error).message);}finally{if(alive.current&&ticket===searchToken.current)setSearching(false);}}
|
||||
async function send(kind:'save'|'logout'){if(sending.current||stale||!view)return;if(!pending.current)pending.current={kind,body:kind==='save'?{requestId:crypto.randomUUID(),revision:view.revision,values}:{requestId:crypto.randomUUID(),revision:view.revision,confirmed:true}};sending.current=true;setBusy(true);setError('');setNotice('');try{const p=pending.current;const result=p.kind==='save'?await put<View>(base,p.body):await post<View>(base+'/logout',p.body);accept(result);pending.current=null;setUncertain(false);setNotice(p.kind==='logout'?'Die Geräte-Abmeldung wurde vorgemerkt. Die Ausführung auf dem Gerät ist noch nicht bestätigt.':result.values.appBlocked?'Fahrerprofil gespeichert, App-Zugang gesperrt und Geräte-Abmeldung vorgemerkt.':'Fahrerprofil gespeichert.');}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setStale(e instanceof ApiError&&e.status===409);}}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title="Fahrerprofil und App-Zugang" wide onClose={()=>void close()}><div className="modal-body admin-dialog-body">
|
||||
{view&&<h3>{view.label}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{busy&&!view&&<p>Fahrerprofil wird geladen …</p>}
|
||||
{view&&<><fieldset disabled={locked}><legend>Fahrerprofil</legend><div className="form-grid"><Field label="Land der Nationalität"><select value={values.nationality} onChange={e=>setValues(v=>({...v,nationality:e.target.value}))}><option value="">Nicht festgelegt</option>{values.nationality&&!view.countries.some(c=>c.code===values.nationality)&&<option value={values.nationality}>{values.nationality} · bestehender Wert</option>}{view.countries.map(c=><option key={c.code} value={c.code}>{c.label}</option>)}</select></Field><Field label="Geburtstag"><input type="date" min="1900-01-01" value={values.birthdate||''} onChange={e=>setValues(v=>({...v,birthdate:e.target.value||null}))}/></Field></div>
|
||||
<h3>Fährt für Unternehmer</h3><p>{parentLabel}</p><div className="admin-toolbar"><Field label="Niederlassung für Unternehmersuche"><select value={searchHq} onChange={e=>{searchToken.current++;setSearching(false);setSearchHq(Number(e.target.value));setParents(null);}}>{view.headquarters.map(h=><option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><Field label="Unternehmer nach Name oder ExtID suchen"><input type="search" maxLength={100} value={query} onChange={e=>{searchToken.current++;setSearching(false);setQuery(e.target.value);setParents(null);}} onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault();void search();}}}/></Field><button type="button" className="button" disabled={searching} onClick={()=>void search()}>Unternehmer suchen</button><button type="button" className="button" disabled={!values.parent} onClick={()=>{setValues(v=>({...v,parent:0}));setParentLabel('Kein Unternehmer zugeordnet');}}>Zuordnung entfernen</button></div>
|
||||
{searchError&&<p role="alert">{searchError}</p>}{searching&&<p>Unternehmer werden gesucht …</p>}{parents&&<><ul>{parents.items.map(p=><li key={p.id}><button type="button" className="button" onClick={()=>{setValues(v=>({...v,parent:p.id}));setParentLabel(p.label);setParents(null);}}>{p.label}</button></li>)}</ul>{!parents.items.length&&<p>Keine passenden Unternehmer.</p>}{parents.truncated&&<p>Weitere Treffer vorhanden. Bitte die Suche eingrenzen.</p>}</>}
|
||||
<CourierAvailability courier={courier} refreshKey={view.lastLogout?.requestedAt}/><h3>App-Zugang</h3><label className="checkbox-label"><input type="checkbox" checked={values.appBlocked} onChange={e=>setValues(v=>({...v,appBlocked:e.target.checked}))}/>App-Zugang gesperrt</label><p className="form-hint">Beim Speichern einer Sperre wird auch die Abmeldung auf dem VPA-/PDA-Gerät angefordert.</p></fieldset>
|
||||
<section className="business-section"><h3>Geräte-Abmeldung</h3><p>Die Abmeldung wird als Geräteauftrag vorgemerkt. Die Ausführung hängt von der Geräteverbindung ab.</p>{view.lastLogout&&<p>Zuletzt vorgemerkt: {view.lastLogout.requestedAt.replace('T',' ')} · gültig bis {view.lastLogout.validUntil.replace('T',' ')}</p>}<button type="button" className="button" disabled={locked||dirty} onClick={()=>void send('logout')}>Geräte-Abmeldung vormerken</button>{dirty&&<p className="form-hint">Änderungen am Fahrerprofil zuerst speichern.</p>}</section></>}
|
||||
{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={busy||stale||!view||!dirty&&!uncertain} onClick={()=>void send(pending.current?.kind||'save')}>{uncertain?'Ergebnis erneut abrufen':'Fahrerprofil speichern'}</button></div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {HistoricalOrdersButton} from "./HistoricalOrders";
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
import type {User} from './types';
|
||||
import {CourierDisposition} from './CourierDisposition';
|
||||
import {CourierBourse} from './CourierBourse';
|
||||
type Options={disposition:boolean;bourse:boolean;name:string;canRunning:boolean;canInvoiceText:boolean;canRemarks:boolean;commissionColumns:boolean;canVehicleFilter:boolean;vehicles:{id:string;label:string}[]};
|
||||
type Query={from:string;to:string;status:number;vehicle:string;invoiceText:boolean};
|
||||
type Row={vehicleType:string;serviceFee:number;fuelIncluded:boolean;incomplete:boolean;id:number;creator:string;vehicle:string;ordered:string|null;finished:string|null;booked:string|null;stops:{company:string;address:string;person:string;remark:string}[];correction:string;invoiceText:string;filters:string[];lines:{label:string;amount:number}[];amount:number;commissionFree:boolean;remark:string;canRemark:boolean;revision:string;settlement:boolean};
|
||||
type Report={rows:Row[];normal:number;commissionFree:number;perJob:number;commission:number;settlement:number;commissionColumns:boolean;from:string;to:string};
|
||||
const day=(d:Date)=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string|null)=>s?new Date(s).toLocaleString('de-DE'):'–';
|
||||
export function CourierPortal({user,onLogout}:{user:User;onLogout:()=>Promise<void>}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const now=new Date(),[options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query>({from:day(new Date(now.getFullYear(),now.getMonth(),1)),to:day(now),status:2,vehicle:'',invoiceText:false}),[applied,setApplied]=useState<Query|null>(null),[data,setData]=useState<Report|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[remark,setRemark]=useState<Row|null>(null),[account,setAccount]=useState(false),[refresh,setRefresh]=useState(0),[history,setHistory]=useState<Query|null>(null);
|
||||
const [archiveOpen,setArchiveOpen]=useState(false);
|
||||
const [bourse,setBourse]=useState(false),[disposition,setDisposition]=useState(false);
|
||||
const locked=archiveOpen||busy||remark!==null||account||history!==null;
|
||||
useEffect(()=>{const c=new AbortController();setError('');request<Options>('/courier-portal',{signal:c.signal}).then(v=>{if(!c.signal.aborted)setOptions(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[refresh]);
|
||||
useEffect(()=>{if(!applied)return;const c=new AbortController();setBusy(true);setError('');setData(null);request<Report>('/courier-portal/query',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied,refresh]);
|
||||
function show(e:FormEvent){e.preventDefault();setNotice('');setApplied({...query});}
|
||||
async function logout(){if(locked)return;setBusy(true);try{await onLogout();}catch(e){setError((e as Error).message);setBusy(false);}}
|
||||
if(disposition)return <CourierDisposition onClose={()=>setDisposition(false)}/>;
|
||||
if(bourse)return <CourierBourse headquarters={user.headquartersId} onClose={()=>setBourse(false)}/>;
|
||||
return <main className="warehouse-portal courier-portal" style={{maxWidth:1500,margin:'0 auto',padding:24}}><header className="admin-title"><div><h1>Transporteurportal</h1><p>{options?.name||user.username} · Niederlassung {user.headquartersId}</p></div><div className="admin-toolbar"><HistoricalOrdersButton disabled={locked} onActive={setArchiveOpen}/>{options?.disposition&&<button className="button" disabled={locked} onClick={()=>setDisposition(true)}>Fahrzeugbelegung</button>}{options?.bourse&&<button className="button" disabled={locked} onClick={()=>setBourse(true)}>Auftragsbörse</button>}<button className="button" disabled={locked} onClick={()=>setRefresh(n=>n+1)}>{uiText("Aktualisieren")}</button><button className="button" disabled={locked} onClick={()=>setAccount(true)}>Passwort ändern</button><button className="button" disabled={locked} onClick={()=>void logout()}>{uiText("Abmelden")}</button></div></header>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{options&&<section className="panel" style={{padding:20}}><h2>Auftragsübersicht</h2><form onSubmit={show}><fieldset disabled={locked}><div className="admin-toolbar"><Field label="Von"><input type="date" required value={query.from} onChange={e=>setQuery({...query,from:e.target.value})}/></Field><Field label="Bis"><input type="date" required value={query.to} onChange={e=>setQuery({...query,to:e.target.value})}/></Field><Field label="Auftragsstatus"><select value={query.status} onChange={e=>setQuery({...query,status:Number(e.target.value)})}><option value={2}>Erledigt</option>{options.canRunning&&<option value={1}>Laufend</option>}</select></Field>{options.canVehicleFilter&&<Field label={uiText("Fahrzeug")}><select value={query.vehicle} onChange={e=>setQuery({...query,vehicle:e.target.value})}><option value="">Alle freigegebenen Fahrzeuge</option>{options.vehicles.map(v=><option value={v.id} key={v.id}>{v.label}</option>)}</select></Field>}{options.canInvoiceText&&<label className="admin-check"><input type="checkbox" checked={query.invoiceText} onChange={e=>setQuery({...query,invoiceText:e.target.checked})}/>Rechnungstext anzeigen</label>}<button className="button primary">{uiText("Anzeigen")}</button>{options.canVehicleFilter&&<button type="button" className="button" disabled={!query.vehicle} onClick={()=>setHistory({...query})}>Log anzeigen</button>}</div></fieldset></form><p className="form-hint">{query.status===1?'Zeitraum nach Auftragszeit.':'Zeitraum nach Erledigungszeit.'} Aktueller Auftragsbestand. Historische Aufträge öffnen Sie über den Archivzugang.</p></section>}
|
||||
{busy&&<p>Aufträge werden geladen …</p>}{data&&<section className="panel" style={{padding:20}}><div className="admin-title"><h2>{data.rows.length} {uiText(" Aufträge")}</h2><span>{data.from} – {data.to}</span></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Auftrag / Erfasser / Fahrzeug</th><th>Auftrag / Annahme / Erledigung</th><th>Tour</th><th>{uiText("Leistung")}</th><th>{data.commissionColumns?'Auftragswert':uiText("Preis")}</th>{data.commissionColumns&&<th>Provisionsfrei</th>}<th>{uiText("Bemerkung")}</th></tr></thead><tbody>{data.rows.map(r=><tr key={r.id}><td><strong>#{r.id}</strong>{r.incomplete&&<p className="alert info">Unvollständig</p>}<p>{r.creator}</p>{r.vehicle&&<p>{r.vehicle}</p>}</td><td>{date(r.ordered)}<br/>{date(r.booked)}<br/>{date(r.finished)}</td><td>{r.stops.map((s,i)=><div key={i} style={{marginBottom:12}}><strong>{s.company}</strong><br/>{s.address}<br/>{s.person}<p style={{whiteSpace:'pre-wrap'}}>{s.remark}</p></div>)}{r.correction&&<p style={{whiteSpace:'pre-wrap'}}>{r.correction}</p>}{r.invoiceText&&<p style={{whiteSpace:'pre-wrap'}}>{r.invoiceText}</p>}{r.filters.length>0&&<p>Anforderungen: {r.filters.join(', ')}</p>}</td><td>{r.lines.map((l,i)=><div key={i}>{l.label}: {money(l.amount)}</div>)}{r.vehicleType&&<p>({r.vehicleType})</p>}{r.serviceFee!==0&&<p>inkl. Servicevergütung {money(r.serviceFee)}</p>}{r.fuelIncluded&&<p>inkl. Treibstoffzuschlag</p>}{r.settlement&&<p>Offener Betrag</p>}</td><td>{!r.commissionFree?money(r.amount):'–'}</td>{data.commissionColumns&&<td>{r.commissionFree?money(r.amount):'–'}</td>}<td><p style={{whiteSpace:'pre-wrap'}}>{r.remark}</p>{r.canRemark&&<button className="button" disabled={locked} onClick={()=>setRemark(r)}>Bemerkung bearbeiten</button>}</td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine Aufträge in diesem Zeitraum.</p>}<dl className="form-grid"><div><dt>Gesamtsumme</dt><dd>{money(data.normal)}</dd></div>{data.commissionColumns&&<div><dt>Provisionsfrei</dt><dd>{money(data.commissionFree)}</dd></div>}{data.commission>0&&<><div><dt>Provision · {data.rows.length} Aufträge × {money(data.perJob)}</dt><dd>−{money(data.commission)}</dd></div><div><dt>Abrechnungssumme</dt><dd>{money(data.settlement)}</dd></div></>}</dl></section>}
|
||||
{history&&<CourierHistory query={history} onClose={()=>setHistory(null)}/>}
|
||||
{remark&&<CourierRemark row={remark} onClose={()=>setRemark(null)} onSaved={()=>{setRemark(null);setNotice('Kurierbemerkung gespeichert.');setRefresh(n=>n+1);}}/>}{account&&<CourierPassword onClose={()=>setAccount(false)} onLogout={onLogout}/>}</main>;
|
||||
}
|
||||
function CourierRemark({row,onClose,onSaved}:{row:Row;onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [value,setValue]=useState(row.remark),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[discard,dialog]=useDiscardConfirmation(),pending=useRef<unknown|null>(null),sending=useRef(false);
|
||||
async function close(){if(sending.current||uncertain)return;if(value===row.remark||await discard('Ungespeicherte Kurierbemerkung verwerfen?'))onClose();}
|
||||
async function save(e:FormEvent){e.preventDefault();if(sending.current||stale)return;pending.current??={requestId:crypto.randomUUID(),job:row.id,revision:row.revision,value};sending.current=true;setBusy(true);setError('');try{await post('/courier-portal/remark',pending.current);setUncertain(false);onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title={`Kurierbemerkung · Auftrag #${row.id}`} onClose={()=>void close()}><form onSubmit={save}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}<Field label="Kurierbemerkung"><textarea maxLength={255} rows={5} disabled={busy||uncertain||stale} value={value} onChange={e=>setValue(e.target.value)}/></Field>{uncertain&&<p>Die Bestätigung fehlt. Bitte denselben Vorgang erneut abrufen.</p>}{stale&&<p>Bitte den Dialog schließen und die Übersicht aktualisieren.</p>}</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button primary" disabled={busy||stale||!uncertain&&value===row.remark}>{uncertain?'Ergebnis erneut abrufen':'Bemerkung speichern'}</button></div></form></Modal>{dialog}</>;
|
||||
}
|
||||
function CourierPassword({onClose,onLogout}:{onClose:()=>void;onLogout:()=>Promise<void>}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<{account:string;revision:string;twoFactorActive:boolean}|null>(null),[currentPassword,setCurrent]=useState(''),[password,setPassword]=useState(''),[confirmation,setConfirmation]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[discard,dialog]=useDiscardConfirmation(),pending=useRef<unknown|null>(null),sending=useRef(false);
|
||||
useEffect(()=>{const c=new AbortController();request<{account:string;revision:string;twoFactorActive:boolean}>('/courier-portal/account',{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[]);
|
||||
async function close(){if(sending.current||uncertain)return;if(!currentPassword&&!password&&!confirmation||await discard('Ungespeicherte Passwortangaben verwerfen?'))onClose();}
|
||||
async function save(e:FormEvent){e.preventDefault();if(sending.current||!data||stale)return;if(!uncertain&&(password!==confirmation||password.length<8||!/[A-Za-z]/.test(password)||!/[0-9]/.test(password))){setError('Bitte übereinstimmende Passwörter mit mindestens acht Zeichen, einem Buchstaben und einer Ziffer eingeben.');return;}
|
||||
pending.current??={requestId:crypto.randomUUID(),revision:data.revision,currentPassword,password,confirmation};sending.current=true;setBusy(true);setError('');try{await post('/courier-portal/password',pending.current);setCurrent('');setPassword('');setConfirmation('');pending.current=null;await onLogout();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}finally{sending.current=false;setBusy(false);}}
|
||||
return <><Modal title="Passwort ändern" onClose={()=>void close()}><form onSubmit={save}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{data&&<p>{data.account} · Zwei-Faktor-Anmeldung: {data.twoFactorActive?'aktiv':'nicht aktiviert'}</p>}<fieldset disabled={busy||uncertain||stale||!data}><div className="form-grid"><Field label="Aktuelles Passwort"><input type="password" required autoComplete="current-password" value={currentPassword} onChange={e=>setCurrent(e.target.value)}/></Field><Field label={uiText("Neues Passwort")}><input type="password" required minLength={8} maxLength={128} autoComplete="new-password" value={password} onChange={e=>setPassword(e.target.value)}/></Field><Field label="Passwort wiederholen"><input type="password" required minLength={8} maxLength={128} autoComplete="new-password" value={confirmation} onChange={e=>setConfirmation(e.target.value)}/></Field></div></fieldset><p className="form-hint">Nach dem Speichern melden Sie sich mit dem neuen Passwort erneut an. Ihre Zwei-Faktor-Einstellung bleibt erhalten.</p>{uncertain&&<p>Die Bestätigung fehlt. Bitte erneut abrufen; falls die Sitzung bereits beendet wurde, melden Sie sich mit dem neuen Passwort an.</p>}{stale&&<p>Bitte den Dialog schließen und erneut öffnen.</p>}</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button primary" disabled={!data||busy||stale}>{uncertain?'Ergebnis erneut abrufen':'Passwort speichern'}</button></div></form></Modal>{dialog}</>;
|
||||
}
|
||||
|
||||
function CourierHistory({query,onClose}:{query:Query;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<{truncated:boolean;rows:{id:number;at:string;job:number;vehicle:string;action:string;name:string;firstName:string;phone:string;detail:string}[]}|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();request<typeof data>('/courier-portal/history',{method:'POST',body:JSON.stringify({from:query.from,to:query.to,vehicle:query.vehicle}),signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[query]);
|
||||
return <Modal title={`Transporteurhistorie · Fahrzeug ${query.vehicle}`} wide onClose={onClose}><div className="modal-body"><p>{query.from} – {query.to}. Die Historie umfasst den Transporteur des ausgewählten Fahrzeugs.</p>{error&&<p className="alert error" role="alert">{error}</p>}{!data&&!error&&<p>Historie wird geladen …</p>}{data&&<>{data.truncated&&<p className="alert info">Die ersten 4000 Einträge werden angezeigt. Bitte den Zeitraum eingrenzen.</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Vorgang")}</th><th>{uiText("Zeitpunkt")}</th><th>{uiText("Auftrag")}</th><th>{uiText("Fahrzeug")}</th><th>{uiText("Name")}</th><th>{uiText("Vorname")}</th><th>{uiText("Telefon")}</th><th>{uiText("Details")}</th></tr></thead><tbody>{data.rows.map(r=><tr key={r.id}><td>{r.action}</td><td>{date(r.at)}</td><td>{r.job||'–'}</td><td>{r.vehicle}</td><td>{r.name}</td><td>{r.firstName}</td><td>{r.phone}</td><td style={{whiteSpace:'pre-wrap'}}>{r.detail}</td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine Historieneinträge in diesem Zeitraum.</p>}</>}</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import {contactLinks} from './contactLinks.mjs';
|
||||
import {ContactVcard} from './ContactVcard';
|
||||
import {isContactEmail} from './contactEmails.mjs';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import { useEffect, useState } from "react";
|
||||
import { request, post, put } from "./api";
|
||||
import { Field, Modal, useDiscardConfirmation } from "./FormElements";
|
||||
@@ -6,42 +10,46 @@ 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 };
|
||||
type Contact = { id: number; headquartersId: number; header?: string|null;cardFieldIds?:number[]|null; fields: { fieldId: number; label: string; type: string; value: string; displayValue?: string }[] };
|
||||
type Detail = { presetWarnings?:string[]; id: number; headquartersId: number; values: Record<number, string>; customerLabels?: Record<string, string>; revision: string; schema: Schema };
|
||||
const label = (s: string) => s.replace(/ /g, " ").replace(/&/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 }) {
|
||||
export function CrmContacts({ options, onDirtyChange, initialCustomer }: { options: CrmOptions; onDirtyChange: (d: boolean) => void; initialCustomer?:AppointmentOption }) {
|
||||
const uiText=useUiText();
|
||||
|
||||
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 [customer, setCustomer] = useState<AppointmentOption | null>(initialCustomer??null), [items, setItems] = useState<Contact[]>([]), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false);
|
||||
const [vcardContact,setVcardContact]=useState<Contact|null>(null);
|
||||
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>
|
||||
async function open(c?: Contact) { setBusy(true); setError(""); try { setEdit(c ? await request<Detail>(`/crm/contacts/${c.id}?headquarters=${c.headquartersId}`) : await request<Detail>(`/crm/contacts/draft?headquarters=${createHq}${customer?`&customerId=${customer.id}`:""}`)); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
|
||||
return <section className="panel crm-panel"><div className="admin-title"><div><h2>{uiText("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>
|
||||
<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">{uiText("Teilwort")}</option><option value="prefix">{uiText("Präfix")}</option></select></Field><Field label="Anfangsbuchstabe des Nachnamens"><select value={letter} onChange={e => setLetter(e.target.value)}><option value="">{uiText("Alle")}</option>{Array.from("ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ").map(c => <option key={c}>{c}</option>)}</select></Field><button className="button primary" disabled={!hqs.length}>{uiText("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>}
|
||||
<div className="admin-toolbar"><strong>{items.length} {uiText(" Kontakte")}</strong><button className="button" aria-pressed={cards} onClick={() => setCards(true)}>Karten</button><button className="button" aria-pressed={!cards} onClick={() => setCards(false)}>{uiText("Liste")}</button></div>
|
||||
{cards ? <div className="crm-contact-grid">{items.map(c => <article className="crm-contact" key={`${c.headquartersId}-${c.id}`}><h3>{contactTitle(c)} <small>{options.headquarters.find(h => h.id === c.headquartersId)?.label}</small></h3><dl>{(Array.isArray(c.cardFieldIds)?c.fields.filter(f=>f.value&&c.cardFieldIds!.includes(f.fieldId)):c.fields.filter(f => f.value && !["Name", "Vorname"].includes(label(f.label))).slice(0, 8)).map(f => <div key={f.fieldId}><dt>{label(f.label)}</dt><dd><ContactValue field={f}/></dd></div>)}</dl><ContactActions contact={c} busy={busy} onOpen={c=>void open(c)} onVcard={setVcardContact}/></article>)}</div> : <div className="crm-contact-list">{options.headquarters.map(h=>{const rows=items.filter(c=>c.headquartersId===h.id);if(!rows.length)return null;const columns=rows[0].fields;return <section key={h.id}><h3>Kontakte · {h.label}</h3><div className="crm-table-scroll crm-contact-table" tabIndex={0} role="region" aria-label={`Kontaktliste ${h.label} horizontal scrollen`}><table aria-label={`Kontaktliste ${h.label}`}><thead><tr><th scope="col">Kontakt / Aktionen</th>{columns.map(f=><th scope="col" key={f.fieldId}>{label(f.label)}</th>)}</tr></thead><tbody>{rows.map(c=><tr key={c.id}><th scope="row"><strong>{contactTitle(c)}</strong><ContactActions contact={c} busy={busy} onOpen={c=>void open(c)} onVcard={setVcardContact}/></th>{columns.map(f=><td key={f.fieldId}><ContactValue field={c.fields.find(v=>v.fieldId===f.fieldId)}/></td>)}</tr>)}</tbody></table></div></section>})}</div>}{!busy && !items.length && <p>Keine Kontakte für diese Auswahl.</p>}
|
||||
{vcardContact&&<ContactVcard id={vcardContact.id} headquarters={vcardContact.headquartersId} onClose={()=>setVcardContact(null)} onDirtyChange={onDirtyChange}/>}
|
||||
{edit && <ContactEditor contact={edit} onDirtyChange={onDirtyChange} onClose={() => setEdit(null)} onSaved={async message => { setEdit(null); onDirtyChange(false); setNotice(message); await load(); }} />}</section>;
|
||||
}
|
||||
function contactTitle(c:Contact){return (c.header??c.fields.filter(f=>["Name","Vorname"].includes(label(f.label))).map(f=>f.value).join(", "))||`Kontakt ${c.id}`;}
|
||||
function ContactValue({field:f}:{field?:Contact["fields"][number]}){if(!f||!f.value)return <>—</>;return <>{f.displayValue??contactLinks(f.type,f.value).map((link,index)=><span key={index}>{index>0?", ":""}{link.href?<a href={link.href} target={link.href.startsWith("http")?"_blank":undefined} rel={link.href.startsWith("http")?"noopener noreferrer":undefined}>{link.label}</a>:link.label}</span>)}</>;}
|
||||
function ContactActions({contact,busy,onOpen,onVcard}:{contact:Contact;busy:boolean;onOpen:(c:Contact)=>void;onVcard:(c:Contact)=>void}){return <div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>onOpen(contact)}>Kontakt öffnen</button><button className="button" disabled={busy} onClick={()=>onVcard(contact)}>vCard</button></div>;}
|
||||
function ContactEditor({ contact: c, onDirtyChange, onClose, onSaved }: { contact: Detail; onDirtyChange: (d: boolean) => void; onClose: () => void; onSaved: (message: string) => Promise<void> }) {
|
||||
const uiText=useUiText();
|
||||
|
||||
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);
|
||||
const [customerLabels,setCustomerLabels]=useState(c.customerLabels??{}),[presetWarnings,setPresetWarnings]=useState(c.presetWarnings??[]);
|
||||
const [ask, dialog] = useDiscardConfirmation(); const changed = !c.id&&Object.values(values).some(v=>!!v) || 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 applyCustomer(field:number){const customer=values[field];if(!customer||busy)return;setBusy(true);setError("");try{const draft=await request<Detail>(`/crm/contacts/draft?headquarters=${c.headquartersId}&customerId=${encodeURIComponent(customer)}`);if(draft.schema.revision!==c.schema.revision)throw new Error("Die Kontaktfelder wurden geändert. Bitte den Kontakt erneut öffnen.");setValues(v=>({...v,...draft.values}));setCustomerLabels(v=>({...v,...draft.customerLabels}));setPresetWarnings(draft.presetWarnings??[]);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
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>
|
||||
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>}{!!presetWarnings.length&&<ul className="alert info">{presetWarnings.map(w=><li key={w}>{w}</li>)}</ul>}<fieldset disabled={busy}><div className="appointment-form-grid">{c.schema.fields.map(f => f.type === "co2cs" ? <div key={f.id}><Lookup<AppointmentOption> label={label(f.label)} endpoint="/appointments/customers" value={values[f.id] ? { id: +values[f.id], label: customerLabels[values[f.id]] ?? `Kunde ${values[f.id]}` } : null} describe={o => o.label} onSelect={o => {setValues({ ...values, [f.id]: o ? String(o.id) : "" });if(o)setCustomerLabels(v=>({...v,[String(o.id)]:o.label}));}} /><button className="button" type="button" disabled={busy||!values[f.id]} onClick={()=>void applyCustomer(f.id)}>Kundendaten übernehmen</button><small className="form-hint">Übernimmt die eingerichteten Kontaktvorgaben, zum Beispiel Anschrift und Telefon. Vorhandene Werte dieser Felder werden ersetzt.</small></div> : <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="text" inputMode={isContactEmail(f.type)?"email":undefined} aria-describedby={isContactEmail(f.type)?`contact-mail-${f.id}`:undefined} 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 })} />}{isContactEmail(f.type)&&<small id={`contact-mail-${f.id}`} className="form-hint">Mehrere E-Mail-Adressen mit Komma trennen.</small>}</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>}</>;
|
||||
<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()}>{uiText("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" ? 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>}</>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type Scope='NONE'|'ROOT'|'ALL';
|
||||
type Selection={scope:Scope;types:number[]};
|
||||
type Address=Record<string,string|number>;
|
||||
type View={customer:number;label:string;revision:string;source:Address;centers:number};
|
||||
type Draft={revision:string;company:Selection;address:Selection};
|
||||
type Change={center:number;label:string;type:number;before:Address;after:Address;company:boolean;address:boolean};
|
||||
type Preview={fingerprint:string;changes:Change[]};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
const types:Record<number,string>={1:'Leistungsanschrift',2:'Rechnungsanschrift',4:'Abhol- / Lieferadresse'};
|
||||
const empty=():Selection=>({scope:'NONE',types:[]});
|
||||
function AddressText({value}:{value:Address}){return <>{['cscad_comp','cscad_comp2','cscad_comp3','cscad_comp4'].map(k=>value[k]?<div key={k}>{value[k]}</div>:null)}<div>{value.ad_street} {value.cscad_hsno}</div><div>{value.ad_zipcode} {value.ad_city} · {value.ad_country}</div></>;}
|
||||
export function CustomerAddressPropagation({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const base=`/master/customers/${customer}/address-propagation`;
|
||||
const [view,setView]=useState<View|null>(null),[company,setCompany]=useState(empty),[address,setAddress]=useState(empty),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[done,setDone]=useState<number|null>(null),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
const pending=useRef<Command|null>(null),saving=useRef(false);const [discard,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setPreview(null);setError('');setCompany(empty());setAddress(empty());setConfirmed(false);pending.current=null;
|
||||
void request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setView(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,reload]);
|
||||
const dirty=company.scope!=='NONE'||address.scope!=='NONE';
|
||||
function reset(){setPreview(null);setConfirmed(false);pending.current=null;setError('');}
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done!==null||!dirty||await discard('Auswahl für die Kostenstellenübernahme verwerfen?'))onClose();}
|
||||
async function refresh(){if(busy||uncertain)return;if(!dirty||await discard('Auswahl verwerfen und aktuelle Kunden- und Kostenstellendaten laden?'))setReload(n=>n+1);}
|
||||
function draft():Draft{if(!view)throw Error('Kundendaten fehlen.');return {revision:view.revision,company,address};}
|
||||
async function check(){if(busy||uncertain)return;setBusy(true);reset();try{setPreview(await post<Preview>(`${base}/preview`,draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||!confirmed||!preview.changes.length||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};try{const r=await post<{changed:number}>(base,pending.current);pending.current=null;setUncertain(false);setDone(r.changed);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)reset();setError((e as Error).message);}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Kundenangaben in Kostenstellen übernehmen" wide onClose={()=>void close()}><div className="modal-body customer-address-propagation">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Kostenstellen werden geprüft …</p>}
|
||||
{done!==null?<p className="admin-success" role="status">Kundenangaben in {done} Kostenstellenanschriften übernommen.</p>:view&&<>
|
||||
<h3>{view.label}</h3><p>Übernommen werden die gespeicherten Kundenangaben. Firma und Anschrift können getrennt auf die Hauptkostenstelle oder alle {view.centers} Kostenstellen übertragen werden.</p><section className="business-section"><h3>Gespeicherte Kundenanschrift</h3><AddressText value={view.source}/></section>
|
||||
<fieldset disabled={busy||uncertain} className="business-fieldset"><div className="form-grid cols-2">{([{key:'company',label:'Firmenbezeichnungen',value:company,set:setCompany},{key:'address',label:'Postanschrift und Hausnummer',value:address,set:setAddress}]).map(g=><section className="business-section" key={g.key}><Field label={g.label}><select value={g.value.scope} onChange={e=>{const scope=e.target.value as Scope;g.set({scope,types:scope==='NONE'?[]:g.value.types});reset();}}><option value="NONE">Keine Übernahme</option><option value="ROOT">Nur Hauptkostenstelle</option><option value="ALL">Alle Kostenstellen</option></select></Field>{Object.entries(types).map(([id,label])=><label className="checkbox-label" key={id}><input type="checkbox" aria-label={`${g.label}: ${label}`} checked={g.value.types.includes(Number(id))} disabled={g.value.scope==='NONE'} onChange={e=>{g.set({...g.value,types:e.target.checked?[...g.value.types,Number(id)]:g.value.types.filter(t=>t!==Number(id))});reset();}}/>{label}</label>)}</section>)}</div></fieldset>
|
||||
<p className="form-hint">Nur vorhandene Anschriften werden aktualisiert. Zusatzadresse (Typ 3), Ansprechpartner, Telefon, E-Mail und Kostenstellennamen bleiben unverändert. „Alle“ umfasst auch gesperrte, unsichtbare und externe Kostenstellen dieses Kunden.</p>
|
||||
{preview&&<section className="business-section"><h3>Vorschau · {preview.changes.length} Änderungen</h3>{preview.changes.length===0?<p role="status">Für diese Auswahl sind keine Änderungen erforderlich.</p>:<><div className="table-scroll"><table className="data-table customer-propagation-table"><thead><tr><th>Kostenstelle / Adressart</th><th>Bisher</th><th>Nach Übernahme</th></tr></thead><tbody>{preview.changes.map(c=><tr key={`${c.center}-${c.type}`}><th>{c.label} · #{c.center}<br/>{types[c.type]}<br/>{[c.company?'Firma':'',c.address?'Anschrift':''].filter(Boolean).join(' + ')}</th><td><AddressText value={c.before}/></td><td><AddressText value={c.after}/></td></tr>)}</tbody></table></div><label className="checkbox-label"><input type="checkbox" disabled={busy||uncertain} checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Angezeigte Kostenstellen und Änderungen geprüft</label></>}</section>}
|
||||
{uncertain&&<p className="alert info" role="status">Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen; die Übernahme wird nicht doppelt ausgeführt.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done!==null?'Schließen':'Abbrechen'}</button>{done===null&&<><button className="button" disabled={busy||uncertain} onClick={()=>void refresh()}>Neu laden</button>{preview?<button className="button primary" disabled={busy||!confirmed||!preview.changes.length} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Kundenangaben übernehmen'}</button>:<button className="button primary" disabled={busy||!view||!dirty} onClick={()=>void check()}>Übernahme prüfen</button>}</>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type Options={id:number;revision:string;canCancel:boolean;reason:string;createCredit:boolean;amount:number;series:boolean};
|
||||
export function CustomerCancellation({job,onClose,onSaved}:{job:number;onClose:()=>void;onSaved:()=>void}){
|
||||
const [data,setData]=useState<Options|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[done,setDone]=useState<{id:number;credit:number}|null>(null);
|
||||
const sending=useRef(false),pending=useRef<{requestId:string;revision:string;confirmed:boolean}|null>(null),base=`/customer-portal/orders/${job}/cancellation`;
|
||||
useEffect(()=>{const c=new AbortController();request<Options>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base]);
|
||||
async function reload(){if(sending.current||uncertain)return;setBusy(true);setError('');try{setData(await request<Options>(base));setConfirmed(false);setStale(false);pending.current=null;}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(sending.current||!data||!confirmed||!data.canCancel||stale)return;pending.current??={requestId:crypto.randomUUID(),revision:data.revision,confirmed:true};sending.current=true;setBusy(true);setError('');try{const result=await post<{id:number;credit:number}>(base,pending.current);setDone(result);setUncertain(false);pending.current=null;onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setStale(e instanceof ApiError&&e.status===409);}}finally{sending.current=false;setBusy(false);}}
|
||||
function close(){if(!sending.current&&!uncertain)onClose();}
|
||||
return <Modal title={`Auftrag #${job} stornieren`} onClose={close}><div className="modal-body">{error&&<p role="alert" className="alert error">{error}</p>}{!data&&!error&&<p>Stornierung wird geprüft …</p>}{done?<p role="status" className="admin-success">Auftrag #{done.id} wurde kostenfrei storniert.{done.credit>0?` Gegenbuchung #${done.credit} wurde erstellt.`:''}</p>:data&&<>{data.canCancel?<><p>Dieser Auftrag kann ohne Stornogebühr storniert werden.</p>{data.createCredit&&<p>Für den gespeicherten Auftragspreis von {data.amount.toLocaleString('de-DE',{style:'currency',currency:'EUR'})} wird eine negative Gegenbuchung erstellt.</p>}<p>Vorhandene Zahlungsbuchungen bleiben erhalten.{data.series?' Die Dauerauftragsregel läuft weiter.':''}</p><label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy||uncertain||stale} onChange={e=>setConfirmed(e.target.checked)}/>Ich bestätige die kostenfreie Stornierung dieses Auftrags.</label></>:<p className="alert info">{data.reason}</p>}</>}{uncertain&&<p className="alert info">Die Antwort fehlt. Rufen Sie das Ergebnis erneut ab; derselbe Vorgang wird nicht doppelt gebucht.</p>}{stale&&<p>Der Auftrag wurde zwischenzeitlich geändert. Bitte den aktuellen Stand laden und erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={close}>{done?'Zurück zum Auftrag':'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button>}{data?.canCancel&&!done&&<button className="button primary" disabled={busy||stale||!confirmed} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Kostenfrei stornieren'}</button>}</div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {CrmContacts} from './CrmContacts';
|
||||
import type {CrmOptions} from './SalesWorkspace';
|
||||
export function CustomerContacts({id,label,onClose}:{id:number;label:string;onClose:()=>void}){
|
||||
const [options,setOptions]=useState<CrmOptions|null>(null),[error,setError]=useState(''),[dirty,setDirty]=useState(false),[ask,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();request<CrmOptions>('/crm/options',{signal:c.signal}).then(v=>{if(!c.signal.aborted)setOptions(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[]);
|
||||
async function close(){if(!dirty||await ask('Ungespeicherte Kontaktänderungen verwerfen?'))onClose();}
|
||||
return <><Modal title={`Kontakte · ${label}`} wide onClose={()=>void close()}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{options?<CrmContacts options={options} initialCustomer={{id,label}} onDirtyChange={setDirty}/>:!error&&<p>Kontakte werden geladen …</p>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {OrderAddressInput} from './OrderAddressInput';
|
||||
import {request,post,ApiError} from './api';
|
||||
type Center={id:number;parent:number;label:string;visible:number};
|
||||
type Customer={id:number;label:string;base:number;centers:Center[]};
|
||||
type View={customer:number;center:number;base:number;name:string;visible:boolean;revision:string;canManage:boolean;canVisibility:boolean;canAddress:boolean;companyLocked:boolean;external:boolean;centers:Center[];children:Center[];types:{id:number;label:string}[];addressFields:{key:string;label:string;type:string;max:number;required:boolean}[];addresses:{type:number;values:Record<string,string>}[]};
|
||||
type Kind='CREATE'|'RENAME'|'VISIBILITY'|'DELETE'|'ADDRESS';
|
||||
type Preview={planRevision:string;changes:{label:string;before:string;after:string}[]};
|
||||
const base='/customer-portal/centers';
|
||||
export function CustomerCostCenters({customers,customer,center,onClose,onChanged}:{customers:Customer[];customer:number;center:number;onClose:()=>void;onChanged:()=>void}){
|
||||
const uiText=useUiText("admin/cs_csc_list.php");
|
||||
|
||||
const [location,setLocation]=useState({customer,center}),[view,setView]=useState<View|null>(null),[refresh,setRefresh]=useState(0),[hidden,setHidden]=useState(false);
|
||||
const [kind,setKind]=useState<Kind>('CREATE'),[name,setName]=useState(''),[child,setChild]=useState(0),[visible,setVisible]=useState(true),[addressType,setAddressType]=useState(4),[address,setAddress]=useState<Record<string,string>>({}),[skipPostal,setSkipPostal]=useState(false);
|
||||
const [error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[addressBusy,setAddressBusy]=useState(false),[dirty,setDirty]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[preview,setPreview]=useState<Preview|null>(null),[ack,setAck]=useState(false);
|
||||
const pending=useRef<Record<string,unknown>|null>(null),sending=useRef(false),[discard,dialog]=useDiscardConfirmation();
|
||||
const locked=busy||addressBusy||uncertain||stale;
|
||||
const endpoint=base+`/${location.customer}/${location.center}`;
|
||||
function addressOf(v:View,type:number){return Object.fromEntries(v.addressFields.map(f=>[f.key,v.addresses.find(a=>a.type===type)?.values[f.key]??(f.key==='ad_country'?'DE':'')]));}
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setError('');request<View>(endpoint,{signal:c.signal}).then(v=>{if(c.signal.aborted)return;setView(v);setKind(v.canManage?'CREATE':'ADDRESS');setName('');setChild(v.children[0]?.id??0);setVisible(v.visible);const type=v.types.some(t=>t.id===4)?4:v.types[0]?.id??4;setAddressType(type);setAddress(addressOf(v,type));setSkipPostal(false);setDirty(false);setStale(false);setPreview(null);setAck(false);pending.current=null;}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[endpoint,refresh]);
|
||||
function invalidate(){setDirty(true);setError('');setNotice('');setPreview(null);setAck(false);pending.current=null;}
|
||||
async function close(){if(sending.current||busy||addressBusy||uncertain)return;if(!dirty||await discard('Ungespeicherte Kostenstellenänderungen verwerfen?'))onClose();}
|
||||
async function navigate(next:{customer:number;center:number}){if(locked||dirty&&!(await discard('Ungespeicherte Kostenstellenänderungen verwerfen?')))return;setNotice('');setLocation(next);}
|
||||
async function selectKind(next:Kind){if(!view||locked||next===kind||dirty&&!(await discard('Ungespeicherte Änderungen dieses Bereichs verwerfen?')))return;setKind(next);setName(next==='RENAME'?view.name:'');setVisible(view.visible);setAddress(addressOf(view,addressType));setSkipPostal(false);setDirty(false);setPreview(null);setAck(false);pending.current=null;setError('');}
|
||||
async function selectType(type:number){if(!view||dirty&&!(await discard('Ungespeicherte Adressänderungen verwerfen?')))return;setAddressType(type);setAddress(addressOf(view,type));setSkipPostal(false);setDirty(false);setPreview(null);setAck(false);pending.current=null;setError('');}
|
||||
async function prepare(){if(!view||locked||sending.current)return;sending.current=true;setBusy(true);setError('');pending.current={requestId:crypto.randomUUID(),...location,kind,revision:view.revision,planRevision:'',name,child,visible,addressType,address:kind==='ADDRESS'?address:null,skipPostal};try{const p=await post<Preview>(base+'/preview',pending.current);pending.current.planRevision=p.planRevision;setPreview(p);setAck(false);}catch(e){setError((e as Error).message);setStale(e instanceof ApiError&&e.status===409);pending.current=null;setPreview(null);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!pending.current||sending.current||!ack||stale)return;sending.current=true;setBusy(true);setError('');try{await post(base+'/save',pending.current);setUncertain(false);setDirty(false);setNotice('Die Kostenstellenänderung wurde gespeichert.');setPreview(null);pending.current=null;onChanged();setRefresh(n=>n+1);}catch(e){setError((e as Error).message);setUncertain(!(e instanceof ApiError)||e.status===0||e.status>=500);setStale(e instanceof ApiError&&e.status===409);}finally{sending.current=false;setBusy(false);}}
|
||||
const canChange=view&&(kind==='ADDRESS'?view.canAddress:view.canManage);
|
||||
return <><Modal title="Kostenstellenpflege" wide onClose={()=>void close()}><div className="admin-dialog-body modal-body vehicle-planning">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{busy&&!view&&<p>Kostenstelle wird geladen …</p>}
|
||||
{view&&<><fieldset disabled={locked}><div className="admin-toolbar"><Field label={uiText("Kunde")}><select value={location.customer} onChange={e=>{const c=customers.find(x=>x.id===Number(e.target.value));if(c)void navigate({customer:c.id,center:c.base});}}>{customers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Aktuelle Kostenstelle"><select value={location.center} onChange={e=>void navigate({...location,center:Number(e.target.value)})}>{view.centers.filter(c=>hidden||c.visible===1||c.id===location.center||c.id===view.base).map(c=><option key={c.id} value={c.id}>{c.label}{!c.visible?uiText(" (ausgeblendet)"):''}</option>)}</select></Field>{view.canManage&&<label className="admin-check"><input type="checkbox" checked={hidden} onChange={e=>setHidden(e.target.checked)}/>Ausgeblendete Kostenstellen anzeigen</label>}</div>
|
||||
{view.external&&<p>Für externe Kostenstellen steht die freigegebene Adresspflege zur Verfügung.</p>}
|
||||
<div className="admin-toolbar">{view.canManage&&(['CREATE','RENAME','VISIBILITY','DELETE'] as Kind[]).filter(k=>k!=='VISIBILITY'||view.canVisibility).map(k=><button className="button" key={k} aria-pressed={kind===k} onClick={()=>void selectKind(k)}>{{CREATE:'Anlegen',RENAME:'Umbenennen',VISIBILITY:'Sichtbarkeit',DELETE:'Löschen',ADDRESS:'Adressen'}[k]}</button>)}{view.canAddress&&<button className="button" aria-pressed={kind==='ADDRESS'} onClick={()=>void selectKind('ADDRESS')}>{uiText("Adressen")}</button>}</div>
|
||||
</fieldset>
|
||||
{!view.canManage&&!view.canAddress&&<p>Für diesen Zugang ist die Kostenstellenpflege nicht freigegeben.</p>}
|
||||
{canChange&&<form onSubmit={e=>{e.preventDefault();void prepare();}}><fieldset disabled={locked}>
|
||||
{(kind==='CREATE'||kind==='RENAME')&&<><Field label={kind==='CREATE'?'Name der neuen Unterkostenstelle':'Neuer Kostenstellenname'}><input required maxLength={50} value={name} onChange={e=>{invalidate();setName(e.target.value);}}/></Field>{kind==='CREATE'&&<p>Die neue Kostenstelle wird unter „{view.name}“ angelegt und übernimmt die Adressvorgaben der Kundenhauptkostenstelle.</p>}</>}
|
||||
{kind==='VISIBILITY'&&<label className="admin-check"><input type="checkbox" checked={visible} onChange={e=>{invalidate();setVisible(e.target.checked);}}/>Kostenstelle sichtbar</label>}
|
||||
{kind==='DELETE'&&<><Field label="Unterkostenstelle löschen"><select value={child} onChange={e=>{invalidate();setChild(Number(e.target.value));}}>{!view.children.length&&<option value={0}>Keine Unterkostenstellen</option>}{view.children.map(c=><option value={c.id} key={c.id}>{c.label}</option>)}</select></Field><p>Verwendete Kostenstellen und Kostenstellen mit Unterkostenstellen können nicht gelöscht werden.</p></>}
|
||||
{kind==='ADDRESS'&&<><Field label="Adresstyp"><select value={addressType} onChange={e=>void selectType(Number(e.target.value))}>{view.types.map(t=><option value={t.id} key={t.id}>{t.label}</option>)}</select></Field><div className="form-grid">{view.addressFields.map(f=>f.key==='ad_street'?<OrderAddressInput key={f.key} apiBase={endpoint} disabled={locked} address={{street:address.ad_street??'',houseNumber:address.cscad_hsno??'',postalCode:address.ad_zipcode??'',city:address.ad_city??'',country:address.ad_country??'DE'}} onBusy={setAddressBusy} onChange={p=>{invalidate();setAddress(a=>({...a,...(p.street!==undefined?{ad_street:p.street}:{}),...(p.houseNumber!==undefined?{cscad_hsno:p.houseNumber}:{}),...(p.postalCode!==undefined?{ad_zipcode:p.postalCode}:{}),...(p.city!==undefined?{ad_city:p.city}:{}),...(p.country!==undefined?{ad_country:p.country}:{})}));}}/>:<Field key={f.key} label={f.label+(f.required?' *':'')}>{f.type==='textarea'?<textarea value={address[f.key]??''} maxLength={f.max} onChange={e=>{invalidate();setAddress(a=>({...a,[f.key]:e.target.value}));}}/>:<input required={f.required} value={address[f.key]??''} maxLength={f.max} readOnly={view.companyLocked&&/^cscad_comp[234]?$/.test(f.key)} onChange={e=>{invalidate();setAddress(a=>({...a,[f.key]:f.key==='ad_country'?e.target.value.toUpperCase():e.target.value}));}}/>}</Field>)}</div><label className="admin-check"><input type="checkbox" checked={skipPostal} onChange={e=>{invalidate();setSkipPostal(e.target.checked);}}/>Zusätzliche PLZ-/Ortsprüfung überspringen</label></>}
|
||||
<button className="button" disabled={kind==='DELETE'?!child:!dirty}>Änderung prüfen</button>
|
||||
</fieldset></form>}</>}
|
||||
{preview&&<section className="customer-settings-preview"><h3>Vorschau</h3>{preview.changes.map((c,i)=><p key={i}><strong>{c.label}</strong>: {c.before||'–'} → {c.after||'–'}</p>)}<label className="admin-check"><input type="checkbox" disabled={locked} checked={ack} onChange={e=>setAck(e.target.checked)}/>Änderung geprüft</label></section>}
|
||||
{uncertain&&<p>Die Speicherbestätigung fehlt. Bitte dasselbe Ergebnis erneut abrufen.</p>}{stale&&<p>Bitte schließen und den aktuellen Stand neu laden.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||addressBusy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{preview&&<button className="button primary" disabled={busy||addressBusy||stale||!ack} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':uiText("Änderung speichern")}</button>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type Vehicle={id:number;courier:number;sid:string;available:boolean;occupied:boolean};
|
||||
type Options={id:number;current:string;canResetPrevious:boolean;blockers:string[];vehicles:Vehicle[]};
|
||||
type Selection={vehicle:number;courier:number;resetPreviousAvailableTime:boolean};
|
||||
type Preview={revision:string;sid:string;previous:string;loseRanking:boolean;warnings:string[];resetPreviousAvailableTime:boolean};
|
||||
type Command={requestId:string;revision:string;selection:Selection;confirmed:boolean};
|
||||
|
||||
export function CustomerCourierAssignment({job,onClose,onSaved}:{job:number;onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<Options|null>(null),[selection,setSelection]=useState<Selection>({vehicle:0,courier:0,resetPreviousAvailableTime:false}),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[error,setError]=useState('');
|
||||
const sending=useRef(false),pending=useRef<Command|null>(null),base=`/customer-portal/orders/${job}/courier-assignment`;
|
||||
useEffect(()=>{const c=new AbortController();request<Options>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base]);
|
||||
function change(next:Selection){if(busy||uncertain)return;setSelection(next);setPreview(null);setConfirmed(false);setError('');pending.current=null;}
|
||||
async function reload(){if(sending.current||uncertain)return;sending.current=true;setBusy(true);setError('');try{setData(await request<Options>(base));setSelection({vehicle:0,courier:0,resetPreviousAvailableTime:false});setPreview(null);setConfirmed(false);pending.current=null;}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function check(){if(sending.current||uncertain||!selection.vehicle)return;sending.current=true;setBusy(true);setError('');setPreview(null);setConfirmed(false);try{setPreview(await post<Preview>(`${base}/preview`,selection));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(sending.current||!preview||!confirmed)return;pending.current??={requestId:crypto.randomUUID(),revision:preview.revision,selection,confirmed:true};sending.current=true;setBusy(true);setError('');try{await post(base,pending.current);setDone(true);setUncertain(false);pending.current=null;onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown){pending.current=null;setPreview(null);setConfirmed(false);}}finally{sending.current=false;setBusy(false);}}
|
||||
function close(){if(!sending.current&&!uncertain)onClose();}
|
||||
return <Modal title={`Kurier für Auftrag #${job} zuweisen`} onClose={close}><div className="modal-body">
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{!data&&!error&&<p>Bevorzugte Kuriere werden geladen …</p>}
|
||||
{done?<p role="status" className="admin-success">Die Zuweisung für Auftrag #{job} wurde gespeichert.</p>:data&&<>
|
||||
<p>Bisheriger Kurier: {data.current||'Noch nicht zugewiesen'}</p>
|
||||
{data.blockers.length>0?<ul className="alert info">{data.blockers.map(b=><li key={b}>{b}</li>)}</ul>:<fieldset disabled={busy||uncertain} style={{border:0,padding:0}}>
|
||||
<label>Bevorzugter Kurier<select value={`${selection.vehicle}:${selection.courier}`} onChange={e=>{const v=data.vehicles.find(v=>`${v.id}:${v.courier}`===e.target.value);change({...selection,vehicle:v?.id||0,courier:v?.courier||0});}}><option value="0:0">Bitte auswählen</option>{data.vehicles.map(v=><option key={`${v.id}:${v.courier}`} value={`${v.id}:${v.courier}`}>{v.sid}{v.occupied?' · belegt':''}{!v.available?' · nicht verfügbar gemeldet':''}</option>)}</select></label>
|
||||
{!data.vehicles.length&&<p>Derzeit ist kein freigegebener bevorzugter Kurier angemeldet.</p>}
|
||||
{data.canResetPrevious&&<label className="admin-check"><input type="checkbox" checked={selection.resetPreviousAvailableTime} onChange={e=>change({...selection,resetPreviousAvailableTime:e.target.checked})}/>Freimeldezeit des bisherigen Kuriers auf jetzt setzen</label>}
|
||||
<button className="button" disabled={!selection.vehicle} onClick={()=>void check()}>Zuweisung prüfen</button>
|
||||
</fieldset>}
|
||||
{preview&&<section><h3>Zuweisung prüfen</h3><p>{uiText("Kurier ")}{preview.sid} erhält den Auftrag.{preview.previous&&` Die bisherige Zuweisung an ${preview.previous} wird zurückgerufen.`}</p>{preview.warnings.length>0&&<ul>{preview.warnings.map(w=><li key={w}>{w}</li>)}</ul>}<p>{preview.loseRanking?'Die Zuweisung ist mit Rankingverlust verbunden.':'Für diese Zuweisung ist derzeit kein Rankingverlust vorgesehen.'}</p>{preview.resetPreviousAvailableTime&&<p>Die Freimeldezeit des bisherigen Kuriers wird auf jetzt gesetzt.</p>}<label className="admin-check"><input type="checkbox" disabled={busy||uncertain} checked={confirmed} onChange={e=>setConfirmed(e.target.checked)}/>Zuweisung geprüft</label></section>}
|
||||
</>}
|
||||
{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte das Ergebnis erneut abrufen; dieselbe Anfrage wird nicht doppelt ausgeführt.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={close}>{done?'Zurück zum Auftrag':'Abbrechen'}</button>{!done&&<><button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={busy||!preview||!confirmed} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':'Kurier zuweisen'}</button></>}</div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post} from './api';
|
||||
import {ErrorMessage,uncertainWrite} from './BusinessForms';
|
||||
|
||||
type Kind='customers'|'couriers';
|
||||
type Row={customer:number;courier:number;customerLabel:string;courierLabel:string;relation:number;position:number;commission:string;description:string;revision:string};
|
||||
type Data={rows:Row[];revision:string;canEditCommission:boolean;operations:{token:string;canResume:boolean;description:string}[]};
|
||||
type Target=Pick<Row,'customer'|'courier'|'revision'>;
|
||||
type Command={requestId:string;action:string;targets:Target[];relation:number;description:string;commission:string|null;position:number;planRevision:string};
|
||||
type Draft={action:string;targets:Target[];relation:number;description:string;commission:string;originalCommission:string;position:number;maxPosition:number;label:string};
|
||||
type Preview={descriptions:string[];planRevision:string;commissionChanges:number};
|
||||
type Choice={id:number;label:string};
|
||||
const base='/master/relations';
|
||||
const key=(r:Target)=>`${r.customer}-${r.courier}`;
|
||||
const rate=(r:string)=>Number(r).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const target=(r:Row):Target=>({customer:r.customer,courier:r.courier,revision:r.revision});
|
||||
const blank=(action:string):Draft=>({action,targets:[],relation:0,description:'',commission:'0',originalCommission:'0',position:1,maxPosition:1,label:''});
|
||||
export function CustomerCourierRelations({kind,id,onClose}:{kind:Kind;id:number;onClose:()=>void}){
|
||||
const uiText=useUiText("admin/customer_courier.php");
|
||||
|
||||
const [data,setData]=useState<Data|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
|
||||
const [query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[selected,setSelected]=useState<string[]>([]);
|
||||
const [draft,setDraft]=useState<Draft|null>(null),[preview,setPreview]=useState<Preview|null>(null),[search,setSearch]=useState(''),[choices,setChoices]=useState<Choice[]>([]),[more,setMore]=useState(false),[searched,setSearched]=useState(false);
|
||||
const [history,setHistory]=useState<{label:string;rows:{eid:string;value:string;at:string}[]}|null>(null);
|
||||
const pending=useRef<Command|null>(null),original=useRef('');const [discard,discardDialog]=useDiscardConfirmation();
|
||||
async function load(){const d=await request<Data>(`${base}/${kind}/${id}`);setData(d);setSelected([]);setPage(0);}
|
||||
useEffect(()=>{setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));},[kind,id]);
|
||||
const dirty=!!draft&&(JSON.stringify(draft)!==original.current||!!preview);
|
||||
async function closeEditor(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Kunden–Transporteur-Beziehung verwerfen?')){setDraft(null);setPreview(null);pending.current=null;setError('');}}
|
||||
function open(d:Draft){original.current=JSON.stringify(d);setDraft(d);setPreview(null);pending.current=null;setSearch('');setChoices([]);setSearched(false);setMore(false);setError('');}
|
||||
function change(p:Partial<Draft>){setDraft(d=>d?{...d,...p}:d);setPreview(null);pending.current=null;setError('');}
|
||||
async function edit(action:string,row:Row){setBusy(true);setError('');try{const context=await request<Data>(`${base}/customers/${row.customer}`);const current=context.rows.find(r=>r.courier===row.courier);if(!current)throw new Error('Diese Beziehung besteht nicht mehr. Bitte neu laden.');open({...blank(action),targets:[target(current)],relation:current.relation,description:current.description,commission:current.commission,originalCommission:current.commission,position:current.position,maxPosition:Math.max(1,...context.rows.filter(r=>r.relation===1).map(r=>r.position)),label:`${current.customerLabel} ↔ ${current.courierLabel}`});}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
function remove(rows:Row[]){open({...blank('REMOVE'),targets:rows.map(target),label:rows.map(r=>`${r.customerLabel} ↔ ${r.courierLabel}`).join('\n')});}
|
||||
async function find(){setBusy(true);setError('');try{const r=await request<{items:Choice[];more:boolean}>(`${base}/search?kind=${kind==='customers'?'couriers':'customers'}&q=${encodeURIComponent(search)}`);setChoices(r.items);setMore(r.more);setSearched(true);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function choose(c:Choice){setBusy(true);setError('');try{const customer=kind==='customers'?id:c.id,courier=kind==='couriers'?id:c.id;const context=await request<Data>(`${base}/customers/${customer}`);if(context.rows.some(r=>r.courier===courier))throw new Error('Zwischen diesem Kunden und Transporteur besteht bereits eine Beziehung. Bitte den vorhandenen Eintrag bearbeiten.');change({targets:[{customer,courier,revision:context.revision}],label:c.label});setChoices([]);setSearched(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function prepare(){if(!draft||!draft.targets.length)return;setBusy(true);setError('');try{const command:Command={requestId:crypto.randomUUID(),action:draft.action,targets:draft.targets,relation:draft.relation,description:draft.description,commission:data?.canEditCommission&&draft.relation===1&&draft.commission!==draft.originalCommission?draft.commission:null,position:draft.position,planRevision:''};const p=await post<Preview>(`${base}/preview`,command);pending.current={...command,planRevision:p.planRevision};setPreview(p);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(token?:string){if(!token&&!pending.current)return;setBusy(true);setError('');try{const r=await post<{state:string;message?:string}>(token?`${base}/operations/${token}`:base,token?undefined:pending.current);pending.current=null;setUncertain(false);setDraft(null);setPreview(null);if(r.state==='DONE')setNotice('Kunden–Transporteur-Beziehungen gespeichert.');else{setNotice('');setError(r.message||'Der gesicherte Vorgang ist noch offen.');}await load();}catch(e){setUncertain(!!pending.current&&uncertainWrite(e));setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function showHistory(row:Row){setBusy(true);setError('');try{const rows=await request<{eid:string;value:string;at:string}[]>(`${base}/history?customer=${row.customer}&courier=${row.courier}`);setHistory({label:`${row.customerLabel} ↔ ${row.courierLabel}`,rows});}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const blocked=busy||uncertain||!!data?.operations.length;
|
||||
const filtered=data?.rows.filter(r=>(filter==='all'||String(r.relation)===filter)&&`${r.customerLabel} ${r.courierLabel} ${r.description}`.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de')))??[];
|
||||
const shown=filtered.slice(page*100,page*100+100);
|
||||
const titles:Record<string,string>={ADD:'Beziehung anlegen',UPDATE:'Beziehung bearbeiten',REMOVE:'Beziehungen entfernen',MOVE:'Favoritenrang ändern'};
|
||||
return <><Modal title={kind==='customers'?`Transporteursbeziehungen · Kunde ${id}`:`Kundenbeziehungen · Transporteur ${id}`} wide onClose={()=>{if(!draft&&!history&&!busy&&!uncertain)onClose();}}><div className="admin-dialog-body customer-relations">
|
||||
{!draft&&<ErrorMessage message={error}/>} {notice&&<p className="admin-success" role="status">{notice}</p>}
|
||||
<p>Kunden und Transporteure als „Gesperrt“ oder „Besonders erwünscht“ verknüpfen. Die Favoritenreihenfolge gilt je Kunde.</p>
|
||||
{data?.operations.map(o=><section className="report-warning" key={o.token}><p>Ein gesicherter Beziehungsvorgang ist noch offen. {o.description}</p>{!o.canResume&&<p>Der ursprüngliche Bearbeiter muss den Vorgang fortsetzen.</p>}<button className="button" disabled={busy||uncertain||!o.canResume} onClick={()=>void save(o.token)}>Vorgang fortsetzen</button></section>)}
|
||||
<div className="admin-toolbar"><Field label="Beziehungen suchen"><input type="search" value={query} onChange={e=>{setQuery(e.target.value);setPage(0);}}/></Field><Field label="Beziehungsstatus"><select value={filter} onChange={e=>{setFilter(e.target.value);setPage(0);}}><option value="all">Alle Beziehungen</option><option value="1">{uiText("Besonders erwünscht")}</option><option value="0">Gesperrt</option></select></Field><button className="button" disabled={busy||uncertain} onClick={()=>{setError('');setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));}}>Neu laden</button></div>
|
||||
<div className="admin-row-actions"><button className="button primary" disabled={blocked||!data} onClick={()=>open(blank('ADD'))}>Neue Beziehung</button><button className="button" disabled={blocked||!selected.length} onClick={()=>remove(data!.rows.filter(r=>selected.includes(key(r))))}>Markierte entfernen ({selected.length})</button><span>{filtered.length} Beziehungen</span></div>
|
||||
{!data&&<p>Beziehungen werden geladen …</p>}{data&&<div className="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="Sichtbare Beziehungen markieren" disabled={blocked||!shown.length} checked={!!shown.length&&shown.every(r=>selected.includes(key(r)))} onChange={e=>setSelected(s=>e.target.checked?[...new Set([...s,...shown.map(key)])]:s.filter(k=>!shown.some(r=>key(r)===k)))}/></th><th>{kind==='customers'?'Transporteur':uiText("Kunde")}</th><th>Status / Rang</th><th>{uiText("Provision")}</th><th>{uiText("Bemerkung")}</th><th>Aktionen</th></tr></thead><tbody>{shown.map(r=><tr key={key(r)}><td><input type="checkbox" aria-label={`${kind==='customers'?r.courierLabel:r.customerLabel} markieren`} disabled={blocked} checked={selected.includes(key(r))} onChange={e=>setSelected(s=>e.target.checked?[...s,key(r)]:s.filter(k=>k!==key(r)))}/></td><td>{kind==='customers'?r.courierLabel:r.customerLabel}</td><td>{r.relation===1?`Besonders erwünscht · Rang ${r.position}`:r.relation===0?'Gesperrt':`Bestandsstatus ${r.relation}`}</td><td>{rate(r.commission)} %</td><td>{r.description||'–'}</td><td><div className="admin-row-actions"><button className="button" disabled={blocked} onClick={()=>void edit('UPDATE',r)}>{uiText("Bearbeiten")}</button>{r.relation===1&&<button className="button" disabled={blocked} onClick={()=>void edit('MOVE',r)}>Rang ändern</button>}<button className="button" disabled={busy||uncertain} onClick={()=>void showHistory(r)}>Provisionshistorie</button><button className="button" disabled={blocked} onClick={()=>remove([r])}>Entfernen</button></div></td></tr>)}</tbody></table></div>}
|
||||
{data&&!filtered.length&&<p>Keine Beziehungen gefunden.</p>}{filtered.length>100&&<div className="admin-row-actions"><button className="button" disabled={!page} onClick={()=>setPage(p=>p-1)}>Zurück</button><span>Seite {page+1} {uiText(" von ")}{Math.ceil(filtered.length/100)}</span><button className="button" disabled={(page+1)*100>=filtered.length} onClick={()=>setPage(p=>p+1)}>Weiter</button></div>}
|
||||
</div></Modal>
|
||||
{draft&&<Modal title={titles[draft.action]} wide onClose={()=>void closeEditor()}><div className="admin-dialog-body customer-relations"><ErrorMessage message={error}/><fieldset className="business-fieldset" disabled={busy||uncertain}>
|
||||
{draft.action==='ADD'&&<><div className="admin-toolbar"><Field label={kind==='customers'?'Transporteur suchen (Name, EID oder SID)':'Kunde suchen (Name oder EID)'}><input type="search" maxLength={100} value={search} onChange={e=>{setSearch(e.target.value);setChoices([]);setSearched(false);}} onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault();void find();}}}/></Field><button className="button" onClick={()=>void find()}>{uiText("Suchen")}</button></div><div className="business-child-list">{choices.map(c=><button className="button" key={c.id} onClick={()=>void choose(c)}>{c.label}</button>)}</div>{more&&<p>Mehr als 50 Treffer. Bitte die Suche eingrenzen.</p>}{searched&&!choices.length&&<p>Keine passenden Stammdaten gefunden.</p>}</>}
|
||||
{draft.label&&<p className="relation-selection">{draft.label}</p>}
|
||||
{['ADD','UPDATE'].includes(draft.action)&&<><div className="form-grid cols-2"><Field label="Beziehungsart"><select value={draft.relation} onChange={e=>change({relation:Number(e.target.value)})}><option value={0}>Gesperrt</option><option value={1}>{uiText("Besonders erwünscht")}</option></select></Field><Field label="Provision (%)"><input type="number" min={0} max={100} step={0.01} disabled={!data?.canEditCommission||draft.relation!==1} value={draft.commission} onChange={e=>change({commission:e.target.value})}/></Field></div>{!data?.canEditCommission&&<p className="form-hint">Provisionen können mit dem zusätzlichen Provisionsrecht geändert werden. Der bestehende Wert bleibt erhalten.</p>}<Field label={uiText("Bemerkung")}><textarea rows={3} maxLength={255} value={draft.description} onChange={e=>change({description:e.target.value})}/></Field></>}
|
||||
{draft.action==='MOVE'&&<Field label="Neuer Favoritenrang"><input type="number" min={1} max={draft.maxPosition} step={1} value={draft.position} onChange={e=>change({position:Number(e.target.value)})}/></Field>}
|
||||
{draft.action==='REMOVE'&&<p>{draft.targets.length} ausgewählte Beziehungen entfernen. Die verbleibenden Favoriten werden fortlaufend nummeriert; die Provisionshistorie bleibt erhalten.</p>}
|
||||
</fieldset>{preview&&<section className="customer-settings-preview"><h3>Änderungsvorschau</h3><ul>{preview.descriptions.map((d,i)=><li key={i}>{d}</li>)}</ul>{preview.commissionChanges>0&&<p>Provisionsänderungen mit Historieneintrag: {preview.commissionChanges}</p>}</section>}{uncertain&&<p>Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void closeEditor()}>Abbrechen</button>{preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':uiText("Änderung speichern")}</button>:<button className="button primary" disabled={busy||!draft.targets.length} onClick={()=>void prepare()}>Änderung prüfen</button>}</div></Modal>}
|
||||
{history&&<Modal title="Kundenspezifische Provisionshistorie" wide onClose={()=>setHistory(null)}><div className="admin-dialog-body customer-relations"><p>{history.label}</p><div className="table-wrap"><table><thead><tr><th>Gültig ab</th><th>{uiText("Provision")}</th><th>Transporteur-EID</th></tr></thead><tbody>{history.rows.map((r,i)=><tr key={i}><td>{r.at}</td><td>{rate(r.value)} %</td><td>{r.eid}</td></tr>)}</tbody></table></div>{!history.rows.length&&<p>Keine historischen Provisionsstände vorhanden.</p>}</div><div className="modal-footer"><button className="button" onClick={()=>setHistory(null)}>{uiText("Schließen")}</button></div></Modal>}{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {PersonalLinks} from './PersonalLinks';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError} from './api';
|
||||
type Center={id:number;label:string;visible:number};
|
||||
type Customer={id:number;label:string;base:number;centers:Center[]};
|
||||
type Profile={id?:number;name:string;firstName:string;email:string;phone:string;mobile:string;account:string;center:number;centerLabel?:string;root?:boolean};
|
||||
type Report={items:Profile[];page:number;hasNext:boolean;canManage:boolean;canCreate:boolean;self:number;centers:Center[]};
|
||||
type View={profile:Profile;revision:string;root:boolean;self:boolean;canProfile:boolean;canRights:boolean;canMatrix:boolean;canPassword:boolean;canReset:boolean;twoFactorActive:boolean;canDelete:boolean;rights:{id:number;label:string;enabled:boolean;canEdit:boolean}[];centers:{id:number;label:string;selected:boolean;canEdit:boolean}[]};
|
||||
type Kind='PROFILE'|'RIGHTS'|'MATRIX'|'PASSWORD'|'RESET'|'DELETE';
|
||||
const base='/customer-portal/employees';
|
||||
export function CustomerEmployees({customers,customer,center,onClose,onLogout}:{customers:Customer[];customer:number;center:number;onClose:()=>void;onLogout:()=>Promise<void>}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [query,setQuery]=useState({customer,center,descendants:false,search:'',sort:'name',descending:false,page:0}),[applied,setApplied]=useState(query),[report,setReport]=useState<Report|null>(null),[refresh,setRefresh]=useState(0),[error,setError]=useState(''),[busy,setBusy]=useState(false),[employee,setEmployee]=useState<number|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setError('');request<Report>(base+'/query',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(v=>{if(!c.signal.aborted)setReport(v);}).catch(e=>{if(!c.signal.aborted){setError(e.message);setReport(null);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied,refresh]);
|
||||
const locked=busy||employee!==null;
|
||||
function page(n:number){setApplied({...applied,page:n});setQuery(q=>({...q,page:n}));}
|
||||
return <><Modal title="Kundenmitarbeiter" wide onClose={()=>{if(!locked)onClose();}}><div className="modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
<form onSubmit={e=>{e.preventDefault();setApplied({...query,page:0});}}><fieldset disabled={locked}><div className="admin-toolbar">
|
||||
<Field label={uiText("Kunde")}><select value={query.customer} onChange={e=>{const cs=customers.find(c=>c.id===Number(e.target.value));if(cs)setQuery({...query,customer:cs.id,center:cs.base,page:0});}}>{customers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field>
|
||||
<Field label={uiText("Kostenstelle")}><select value={query.center} onChange={e=>setQuery({...query,center:Number(e.target.value),page:0})}>{customers.find(c=>c.id===query.customer)?.centers.map(c=><option value={c.id} key={c.id}>{c.label}{c.visible===0?uiText(" (ausgeblendet)"):''}</option>)}</select></Field>
|
||||
<Field label="Mitarbeiter suchen"><input value={query.search} maxLength={100} onChange={e=>setQuery({...query,search:e.target.value})}/></Field>
|
||||
<label className="admin-check"><input type="checkbox" checked={query.descendants} onChange={e=>setQuery({...query,descendants:e.target.checked})}/>Unterkostenstellen einbeziehen</label><button className="button primary">{uiText("Anzeigen")}</button>
|
||||
</div></fieldset></form>
|
||||
{busy&&<p>Mitarbeiter werden geladen …</p>}{report&&<><div className="admin-toolbar"><button className="button" disabled={locked} onClick={()=>setEmployee(report.self)}>Eigenes Konto</button>{report.canCreate&&<button className="button" disabled={locked} onClick={()=>setEmployee(0)}>Mitarbeiter anlegen</button>}<span>Seite {report.page+1}</span><button className="button" disabled={locked||report.page===0} onClick={()=>page(report.page-1)}>Zurück</button><button className="button" disabled={locked||!report.hasNext} onClick={()=>page(report.page+1)}>Weiter</button></div>
|
||||
{!report.canManage&&<p>Dieser Zugang kann nur das eigene Konto anzeigen.</p>}
|
||||
<div className="table-scroll"><table className="data-table"><thead><tr>{[['name','Name'],['firstName','Vorname'],['email','E-Mail'],['phone','Telefon'],['mobile','Mobil'],['account','Anmeldename'],['center','Kostenstelle']].map(([key,label])=><th key={key}><button className="button" disabled={locked} onClick={()=>{const next={...applied,sort:key,descending:applied.sort===key?!applied.descending:false,page:0};setApplied(next);setQuery(next);}}>{label}{applied.sort===key?(applied.descending?' ↓':' ↑'):''}</button></th>)}<th>{uiText("Konto")}</th></tr></thead><tbody>{report.items.map(p=><tr key={p.id}><td>{p.name}{p.root?' (Hauptzugang)':''}</td><td>{p.firstName}</td><td>{p.email}</td><td>{p.phone}</td><td>{p.mobile}</td><td>{p.account}</td><td>{p.centerLabel}</td><td><button className="button" disabled={locked} onClick={()=>setEmployee(p.id!)}>Öffnen</button></td></tr>)}</tbody></table></div>{!report.items.length&&<p>Keine Mitarbeiter für diese Auswahl.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={locked} onClick={onClose}>{uiText("Schließen")}</button></div></Modal>{employee!==null&&<CustomerEmployeeEditor customer={employee===report?.self?customers[0].id:applied.customer} center={applied.center} employee={employee} onClose={()=>setEmployee(null)} onSaved={()=>{setRefresh(n=>n+1);}} onLogout={onLogout}/>}</>;
|
||||
}
|
||||
function CustomerEmployeeEditor({customer,center,employee,onClose,onSaved,onLogout}:{customer:number;center:number;employee:number;onClose:()=>void;onSaved:()=>void;onLogout:()=>Promise<void>}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<View|null>(null),[refresh,setRefresh]=useState(0),[kind,setKind]=useState<Kind>('PROFILE'),[profile,setProfile]=useState<Profile>({name:'',firstName:'',email:'',phone:'',mobile:'',account:'',center}),[rights,setRights]=useState<number[]>([]),[centers,setCenters]=useState<number[]>([]);
|
||||
const [password,setPassword]=useState(''),[confirmation,setConfirmation]=useState(''),[currentPassword,setCurrentPassword]=useState(''),[account,setAccount]=useState(''),[code,setCode]=useState(''),[ack,setAck]=useState(false),[dirty,setDirty]=useState(false),[busy,setBusy]=useState(employee>0),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<Record<string,unknown>|null>(null),[discard,discardDialog]=useDiscardConfirmation();const locked=busy||uncertain||stale;
|
||||
useEffect(()=>{if(!employee)return;const c=new AbortController();setBusy(true);request<View>(`${base}/${customer}/${employee}`,{signal:c.signal}).then(v=>{if(c.signal.aborted)return;setView(v);setProfile(v.profile);setRights(v.rights.filter(r=>r.enabled).map(r=>r.id));setCenters(v.centers.filter(c=>c.selected).map(c=>c.id));setDirty(false);setStale(false);setAck(false);pending.current=null;}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[customer,employee,refresh]);
|
||||
function changed(){setDirty(true);setAck(false);setError('');setNotice('');pending.current=null;}
|
||||
function clearSecrets(){setPassword('');setConfirmation('');setCurrentPassword('');setAccount('');setCode('');}
|
||||
async function close(){if(sending.current||busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Mitarbeiteränderungen verwerfen?')){clearSecrets();onClose();}}
|
||||
async function tab(next:Kind){if(locked||next===kind||dirty&&!(await discard('Ungespeicherte Mitarbeiteränderungen verwerfen?')))return;setKind(next);if(view){setProfile(view.profile);setRights(view.rights.filter(r=>r.enabled).map(r=>r.id));setCenters(view.centers.filter(c=>c.selected).map(c=>c.id));}clearSecrets();setDirty(false);setAck(false);setError('');setNotice('');pending.current=null;}
|
||||
async function save(){if(sending.current||!ack||stale)return;sending.current=true;setBusy(true);setError('');if(!pending.current)pending.current={requestId:crypto.randomUUID(),customer,employee,center:profile.center,kind:employee?kind:'CREATE',revision:view?.revision??'',profile:kind==='PROFILE'?profile:null,rights:kind==='RIGHTS'?rights:null,centers:kind==='MATRIX'?centers:null,account,currentPassword,password,confirmation,code};
|
||||
try{const result=await post<{id:number;reauthenticate:boolean}>(base+'/save',pending.current);pending.current=null;clearSecrets();setUncertain(false);setDirty(false);setAck(false);setNotice('Die Mitarbeiteränderung wurde gespeichert.');onSaved();if(result.reauthenticate){try{await onLogout();onClose();}catch{setNotice("Die Zugangsdaten wurden geändert. Bitte abmelden und erneut anmelden.");setStale(true);}}else if(!employee||kind==='DELETE')onClose();else setRefresh(n=>n+1);}catch(e){setError((e as Error).message);setUncertain(!(e instanceof ApiError)||e.status===0||e.status>=500);setStale(e instanceof ApiError&&e.status===409);}finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
const editable=!employee||view?.canProfile;
|
||||
const options: [Kind,string,boolean][]=[['PROFILE','Kontaktdaten',true],['RIGHTS','Rechte',!!view?.canRights],['MATRIX','Kostenstellenfreigaben',!!view?.canMatrix],['PASSWORD','Passwort',!!view?.canPassword],['RESET','2FA zurücksetzen',!!view?.canReset&&!!view?.twoFactorActive],['DELETE','Löschen',!!view?.canDelete]];
|
||||
return <><Modal title={employee?`Mitarbeiterkonto ${view?.profile.account??''}`:'Mitarbeiter anlegen'} wide onClose={()=>void close()}><div className="modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{busy&&!view&&employee>0&&<p>Konto wird geladen …</p>}
|
||||
{(view||!employee)&&<><fieldset disabled={locked}><div className="admin-toolbar">{employee>0&&options.filter(o=>o[2]).map(([key,label])=><button className="button" aria-pressed={kind===key} key={key} onClick={()=>void tab(key)}>{label}</button>)}</div></fieldset>
|
||||
{employee>0&&view&&<PersonalLinks kind="customer-employees" id={employee} customer={customer} disabled={locked||dirty}/>}<form onSubmit={e=>{e.preventDefault();void save();}}><fieldset disabled={locked}>
|
||||
{kind==='PROFILE'&&<><div className="form-grid">{([['name','Name',50],['firstName','Vorname',50],['email','E-Mail',250],['phone','Telefon',20],['mobile','Mobil',20],['account','Anmeldename',20]] as const).map(([key,label,max])=><Field key={key} label={label+(!['phone','mobile'].includes(key)?' *':'')}><input readOnly={!editable} type={key==='email'?'email':'text'} required={!['phone','mobile'].includes(key)} maxLength={max} value={profile[key]} onChange={e=>{changed();setProfile(p=>({...p,[key]:e.target.value}));}}/></Field>)}</div>{!editable&&<p>Die Kontaktdaten können mit diesem Zugang nicht geändert werden.</p>}</>}
|
||||
{kind==='RIGHTS'&&view&&<div className="form-grid">{view.rights.map(r=><label className="admin-check" key={r.id}><input type="checkbox" disabled={!r.canEdit} checked={rights.includes(r.id)} onChange={e=>{changed();setRights(v=>e.target.checked?[...v,r.id]:v.filter(i=>i!==r.id));}}/>{r.label}</label>)}</div>}
|
||||
{kind==='MATRIX'&&view&&<div className="form-grid">{view.centers.map(c=><label className="admin-check" key={c.id}><input type="checkbox" disabled={!c.canEdit} checked={centers.includes(c.id)} onChange={e=>{changed();setCenters(v=>e.target.checked?[...v,c.id]:v.filter(i=>i!==c.id));}}/>{c.label}</label>)}</div>}
|
||||
{(!employee||kind==='PASSWORD')&&<div className="form-grid">{employee>0&&<Field label="Aktuelles eigenes Passwort"><input required type="password" autoComplete="current-password" value={currentPassword} onChange={e=>{changed();setCurrentPassword(e.target.value);}}/></Field>}<Field label={uiText("Neues Passwort")}><input required type="password" autoComplete="new-password" value={password} onChange={e=>{changed();setPassword(e.target.value);}}/></Field><Field label="Passwort wiederholen"><input required type="password" autoComplete="new-password" value={confirmation} onChange={e=>{changed();setConfirmation(e.target.value);}}/></Field></div>}
|
||||
{(kind==='DELETE'||kind==='RESET')&&<><p>{kind==='DELETE'?'Das Mitarbeiterkonto und seine persönlichen Freigaben werden entfernt.':'Die Zwei-Faktor-Anmeldung wird zurückgesetzt. Bestehende Sitzungen werden ungültig.'}</p><Field label="Anmeldename zur Bestätigung"><input required value={account} onChange={e=>{changed();setAccount(e.target.value);}}/></Field></>}
|
||||
{kind==='RESET'&&<><Field label="Aktuelles eigenes Passwort"><input required type="password" autoComplete="current-password" value={currentPassword} onChange={e=>{changed();setCurrentPassword(e.target.value);}}/></Field>{view?.self&&<Field label="Aktueller Zwei-Faktor-Code"><input required inputMode="numeric" autoComplete="one-time-code" value={code} onChange={e=>{changed();setCode(e.target.value);}}/></Field>}</>}
|
||||
{(kind!=='PROFILE'||editable)&&<label className="admin-check"><input type="checkbox" checked={ack} onChange={e=>setAck(e.target.checked)}/>Änderung für dieses Mitarbeiterkonto geprüft</label>}
|
||||
</fieldset></form></>}
|
||||
{uncertain&&<p>Die Speicherbestätigung fehlt. Bitte denselben Vorgang erneut abrufen.</p>}{stale&&<p>Der Stand hat sich geändert. Bitte schließen und das Konto erneut öffnen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{(kind!=='PROFILE'||editable)&&<button className="button primary" disabled={busy||stale||!ack||(!dirty&&!uncertain)} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':kind==='DELETE'?'Konto löschen':uiText("Änderung speichern")}</button>}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type Values={flags:Record<string,boolean>;color:string};
|
||||
type View={customer:number;label:string;revision:string;values:Values;canManageFavoriteDispatch:boolean;jobsHidden:boolean};
|
||||
type Draft={revision:string;values:Values};
|
||||
type Preview={fingerprint:string;before:Values;after:Values};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
const labels:Record<string,string>={stationNumbers:'Stationsnummern in der Auftragsdetailansicht anzeigen',entryEnabled:'Kundenseitige Auftragserfassung',automaticDispatch:'Automatische Vermittlung in der Kundenerfassung',creatorDiscount:'Selbsterfassungsrabatt',cancellation:'Kundenseitiges Auftrags-Storno',favoriteDispatch:'Vermittlung an bevorzugte Transporteure',noStandardPrice:'Keine Standard-Preisausgabe in der Kundenerfassung',calculatorOnly:'Ausschließlich Calculator zur Preisberechnung',unverifiedAddresses:'Ungeprüfte Adressen in der Kundenerfassung erlauben',cargo:'Lademaße in der Kundenerfassung',cargoRequired:'Lademaße verpflichtend',incomplete:'Aufträge automatisch auf unfertig setzen',stationReference:'Stationskommissionsnummern verwenden',missingReferenceIncomplete:'Bei fehlender Stationskommissionsnummer auf unfertig setzen',referenceEveryStation:'Kommissionsnummer an jeder Station erforderlich'};
|
||||
function rows(v:Values):[string,string][]{return [...Object.entries(labels).map(([key,label]):[string,string]=>[label,v.flags[key]?'Ja':'Nein']),['Auftragsfarbe',v.color==='FFFFFF'?'Keine Farbe (FFFFFF)':v.color]];}
|
||||
export function CustomerEntrySettings({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${customer}/entry-settings`;
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values|null>(null),[preview,setPreview]=useState<Preview|null>(null);
|
||||
const [busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
const pending=useRef<Command|null>(null),saving=useRef(false);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setValues(null);setPreview(null);setError('');pending.current=null;
|
||||
void request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setView(v);setValues(v.values);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,reload]);
|
||||
const dirty=!!view&&!!values&&!done&&JSON.stringify(values)!==JSON.stringify(view.values);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(!dirty||await discard('Ungespeicherte Kundenerfassungsvorgaben verwerfen?'))onClose();}
|
||||
async function refresh(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Kundenerfassungsvorgaben verwerfen und neu laden?'))setReload(n=>n+1);}
|
||||
function change(patch:Partial<Values>){setValues(v=>v?{...v,...patch}:v);setPreview(null);pending.current=null;setError('');}
|
||||
function draft():Draft{if(!view||!values)throw Error('Bitte zuerst die Kundenvorgaben laden.');return {revision:view.revision,values};}
|
||||
async function check(){if(busy||uncertain)return;setBusy(true);setPreview(null);setError('');try{setPreview(await post<Preview>(`${base}/preview`,draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};
|
||||
try{await post(base,pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){setPreview(null);pending.current=null;}setError((e as Error).message);}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Kundenerfassung" wide onClose={()=>void close()}><div className="modal-body customer-entry-settings">
|
||||
{view&&<h3>{view.label} · #{customer}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Kundenerfassungsvorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Kundenerfassungsvorgaben gespeichert.</p>:view&&values&&<>
|
||||
<fieldset disabled={busy||uncertain}><div className="form-grid cols-2">{Object.entries(labels).map(([key,label])=><label className="admin-check" key={key}><input type="checkbox" checked={values.flags[key]} disabled={key==='favoriteDispatch'&&!view.canManageFavoriteDispatch||key==='missingReferenceIncomplete'&&!values.flags.stationReference||key==='referenceEveryStation'&&!values.flags.missingReferenceIncomplete} onChange={e=>{const flags={...values.flags,[key]:e.target.checked};if(key==='cargo'&&!e.target.checked)flags.cargoRequired=false;if(key==='cargoRequired'&&e.target.checked)flags.cargo=true;if(key==='stationReference'&&!e.target.checked){flags.missingReferenceIncomplete=false;flags.referenceEveryStation=false;}if(key==='missingReferenceIncomplete'&&!e.target.checked)flags.referenceEveryStation=false;change({flags});}}/>{label}</label>)}</div><div className="form-grid cols-2"><Field label="Farbe der Aufträge"><input aria-label="Farbe der Aufträge" type="color" value={'#'+values.color} onChange={e=>change({color:e.target.value.slice(1).toUpperCase()})}/></Field><button type="button" className="button" onClick={()=>change({color:'FFFFFF'})}>Keine Farbe</button></div></fieldset>
|
||||
<p className="form-hint">Aufträge in Listen ausgeblendet: {view.jobsHidden?'Ja':'Nein'} (Bestandsanzeige). {!view.canManageFavoriteDispatch&&'Für Änderungen an der Vermittlung an bevorzugte Transporteure ist Recht 41 erforderlich.'}</p>
|
||||
<p className="form-hint">Die wirksamen Vorgaben gelten für neue Vorgänge in beiden Anwendungen. Der Rabattsatz stammt aus den Niederlassungseinstellungen. Die Stationsnummernanzeige wirkt beim erneuten Öffnen der Detailansicht auch für bestehende Aufträge; deren Geschäftsdaten werden nicht geändert.</p>
|
||||
<p className="form-hint">Kommissionsnummern: Ohne „An jeder Station“ reicht eine ausgefüllte Station. Diese Unfertig-Prüfung gilt für die Zentralenerfassung und den Auftragsabschluss; Kundeneingaben sind davon ausgenommen.</p>
|
||||
<p className="form-hint">Ungeprüfte Adressen: Die Bestandsvorgabe wird gespeichert. Die Aktion zur ungeprüften Adressübernahme ist derzeit in der Kundenerfassung nicht verfügbar.</p>
|
||||
{preview&&<section className="customer-settings-preview"><h3>Geprüfte Änderung</h3><div className="table-scroll"><table className="data-table"><thead><tr><th>Angabe</th><th>Bisher</th><th>Nach dem Speichern</th></tr></thead><tbody>{rows(preview.before).map(([label,value],i)=><tr key={label}><th>{label}</th><td>{value}</td><td>{rows(preview.after)[i][1]}</td></tr>)}</tbody></table></div></section>}
|
||||
{uncertain&&<p role="status" className="alert info">Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen; die Änderung wird nicht doppelt ausgeführt.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void refresh()}>Neu laden</button>}{view&&!done&&(preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Kundenerfassungsvorgaben speichern'}</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import {Field} from './FormElements';
|
||||
import {request} from './api';
|
||||
type Result={rows:{id:number;name:string;reference:string;ordered:string|null}[];page:number;hasNext:boolean};
|
||||
export function CustomerEntryTours({payer,canRead,disabled,onLoad}:{payer:number;canRead:boolean;disabled:boolean;onLoad:(id:number)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [text,setText]=useState(''),[templates,setTemplates]=useState(true),[search,setSearch]=useState({q:'',templates:true,page:0}),[result,setResult]=useState<Result|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setError('');setResult(null);const params=new URLSearchParams({q:search.q,templates:String(search.templates),page:String(search.page),payer:String(payer)});request<Result>(`/customer-portal/entry/tours?${params}`,{signal:c.signal}).then(r=>{if(!c.signal.aborted)setResult(r);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[search,payer]);
|
||||
function show(e:FormEvent){e.preventDefault();setSearch({q:text,templates,page:0});}
|
||||
return <section className="customer-settings-preview"><h3>Gespeicherte Tour oder Auftrag laden</h3><form onSubmit={show}><fieldset disabled={disabled||busy}><div className="admin-toolbar"><Field label="Tourname, Auftragsnummer oder Kommissionsnummer"><input value={text} maxLength={100} onChange={e=>setText(e.target.value)}/></Field>{canRead&&<Field label="Suchen in"><select value={templates?'templates':'orders'} onChange={e=>setTemplates(e.target.value==='templates')}><option value="templates">Gespeicherte Touren</option><option value="orders">Alle eigenen Aufträge</option></select></Field>}<button className="button">{uiText("Suchen")}</button></div></fieldset></form>{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p>Touren werden geladen …</p>}{result&&<><p>Seite {result.page+1} · Zahler #{payer}</p>{result.rows.length===0?<p>Keine passenden Touren.</p>:<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Auftrag")}</th><th>Tourname</th><th>{uiText("Kommissionsnummer")}</th><th>Übernehmen</th></tr></thead><tbody>{result.rows.map(r=><tr key={r.id}><td>#{r.id}</td><td>{r.name||'–'}</td><td>{r.reference||'–'}</td><td><button className="button" disabled={disabled||busy} onClick={()=>onLoad(r.id)}>#{r.id} laden</button></td></tr>)}</tbody></table></div>}<div className="admin-toolbar"><button className="button" disabled={disabled||busy||result.page===0} onClick={()=>setSearch({...search,page:search.page-1})}>Vorherige Tourenseite</button><button className="button" disabled={disabled||busy||!result.hasNext} onClick={()=>setSearch({...search,page:search.page+1})}>Nächste Tourenseite</button></div></>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {MasterExportTransfer} from './MasterExportTransfer';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError,downloadBusinessFile} from './api';
|
||||
type Entry={uploaded?:boolean;id:number;name:string;stored:string;size:number;canDownload:boolean;canRemove:boolean;status:string};
|
||||
type Operation={requestId:string;name:string;bytes:number;rows:number;canResume:boolean};
|
||||
type Removed={requestId:string;name:string;status:'PENDING'|'REMOVED'|'RESTORING';missing:boolean};
|
||||
type Storage={canStore:boolean;message?:string;operations:Operation[]};
|
||||
type View={files:Entry[];page:number;hasNext:boolean;available:boolean;message:string};
|
||||
type Mutation={path:string;body:Record<string,unknown>};
|
||||
|
||||
export function CustomerExportFiles({disabled,onBusy,onAvailability,version,paths}:{disabled:boolean;onBusy:(busy:boolean)=>void;onAvailability:(available:boolean)=>void;version:number;paths?:{files:string;storage:string;removal:string}}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=paths?.files??'/customer-portal/export-files',removal=paths?.removal??'/customer-portal/export-removal',storagePath=paths?.storage??'/customer-portal/export-storage';
|
||||
const [transferBusy,setTransferBusy]=useState(false);
|
||||
const [page,setPage]=useState(0),[refresh,setRefresh]=useState(0),[data,setData]=useState<View|null>(null),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[storage,setStorage]=useState<Storage|null>(null),[removed,setRemoved]=useState<Removed[]>([]),[confirm,dialog]=useDiscardConfirmation({title:"Dateivorgang bestätigen",confirm:"Bestätigen",cancel:"Abbrechen"});
|
||||
const pending=useRef<Mutation|null>(null),sending=useRef(false),locked=disabled||busy||uncertain||transferBusy;
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setError('');Promise.all([request<View>(`${base}?page=${page}`,{signal:c.signal}),request<Storage>(storagePath,{signal:c.signal}),request<{operations:Removed[]}>(removal,{signal:c.signal})]).then(([v,s,r])=>{if(!c.signal.aborted){setData(v);setStorage(s);setRemoved(r.operations);onAvailability(s.canStore);}}).catch(e=>{if(!c.signal.aborted){setError(e.message);setData(null);setStorage(null);setRemoved([]);onAvailability(false);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[page,refresh,version,onAvailability,base,removal,storagePath]);
|
||||
async function download(file:Entry){if(locked)return;setBusy(true);onBusy(true);setError('');try{await downloadBusinessFile(`${base}/${file.id}/download`,null,file.name,'GET');}catch(e){setError((e as Error).message);}finally{setBusy(false);onBusy(false);}}
|
||||
async function perform(m:Mutation){if(sending.current)return;sending.current=true;pending.current=m;setBusy(true);onBusy(true);setError('');setNotice('');try{await post(m.path,m.body);pending.current=null;setUncertain(false);onBusy(false);setNotice('Dateivorgang abgeschlossen.');setRefresh(n=>n+1);}catch(e){setError((e as Error).message);const unclear=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unclear);onBusy(unclear);if(!unclear)pending.current=null;}finally{setBusy(false);sending.current=false;}}
|
||||
async function recover(op:Operation,cancel=false){if(locked)return;if(cancel&&!(await confirm(`Offenen Export „${op.name}“ und seine noch nicht veröffentlichte Datei verwerfen?`)))return;await perform({path:`${storagePath}/${op.requestId}/${cancel?'cancel':'resume'}`,body:{}});}
|
||||
async function remove(file:Entry){if(locked)return;setBusy(true);onBusy(true);setError('');setNotice('');let mutation:Mutation|null=null;try{const v=await request<{name:string;missing:boolean;revision:string}>(`${removal}/${file.id}`);if(await confirm(`Exportdatei „${v.name}“ entfernen? ${v.missing?'Die Datei fehlt bereits; ihr Listeneintrag wird entfernt.':'Die Datei wird in die Wiederherstellungsablage verschoben.'} Sie können den Vorgang hier rückgängig machen.`))mutation={path:removal,body:{requestId:crypto.randomUUID(),file:file.id,revision:v.revision}};}catch(e){setError((e as Error).message);}finally{setBusy(false);onBusy(false);}if(mutation)await perform(mutation);}
|
||||
async function restore(op:Removed){if(locked)return;await perform({path:`${removal}/${op.requestId}/${op.status==='REMOVED'?'restore':'resume'}`,body:{}});}
|
||||
return <><section><h3>Gespeicherte Exportdateien</h3>{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{busy&&<p>Dateivorgang wird bearbeitet …</p>}{uncertain&&<p>Die Bestätigung fehlt. <button className="button primary" disabled={busy} onClick={()=>pending.current&&void perform(pending.current)}>Dateivorgang erneut abrufen</button></p>}{data?.message&&<p>{data.message}</p>}{storage?.message&&storage.message!==data?.message&&<p>{storage.message}</p>}
|
||||
{storage&&storage.operations.length>0&&<section><h4>Offene Speicherungen</h4>{storage.operations.map(op=><div className="admin-toolbar" key={op.requestId}><span>{op.name} · {op.rows} Zeilen</span><button className="button" disabled={locked||!op.canResume} onClick={()=>void recover(op)}>Speicherung fortsetzen</button><button className="button" disabled={locked} onClick={()=>void recover(op,true)}>Offenen Export verwerfen</button>{!op.canResume&&<span>Der Kundenumfang hat sich geändert.</span>}</div>)}</section>}
|
||||
<div className="admin-toolbar"><button className="button" disabled={locked} onClick={()=>setRefresh(n=>n+1)}>Dateiliste aktualisieren</button>{data&&<><button className="button" disabled={locked||page===0} onClick={()=>setPage(n=>n-1)}>Vorherige Dateiseite</button><span>Seite {data.page+1}</span><button className="button" disabled={locked||!data.hasNext} onClick={()=>setPage(n=>n+1)}>Nächste Dateiseite</button></>}</div>
|
||||
{data&&<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Datei</th><th>Gespeichert</th><th>Größe</th><th>{uiText("Status")}</th><th>Aktionen</th></tr></thead><tbody>{data.files.map(f=><tr key={f.id}><td>{f.name}</td><td>{f.stored?new Date(f.stored).toLocaleString('de-DE'):'–'}</td><td>{f.size.toLocaleString('de-DE')} B</td><td>{f.status}</td><td><div className="admin-toolbar"><button className="button" disabled={locked||!f.canDownload} onClick={()=>void download(f)}>Herunterladen</button><button className="button" disabled={locked||!f.canRemove} onClick={()=>void remove(f)}>Entfernen</button></div></td></tr>)}</tbody></table>{!busy&&!data.files.length&&!data.message&&<p>Keine gespeicherten Exportdateien vorhanden.</p>}</div>}
|
||||
{removed.length>0&&<section><h4>Entfernte Dateien und offene Dateivorgänge</h4>{removed.map(op=><div className="admin-toolbar" key={op.requestId}><span>{op.name}{op.missing?' · Datei fehlte bereits':''}</span><button className="button" disabled={locked} onClick={()=>void restore(op)}>{op.status==='REMOVED'?'Wiederherstellen':op.status==='RESTORING'?'Wiederherstellung fortsetzen':'Entfernen fortsetzen'}</button></div>)}</section>}
|
||||
</section>{base==='/data-transfer/master-export-files'&&<MasterExportTransfer files={data?.files??[]} disabled={disabled||busy||uncertain} version={version+refresh} onBusy={value=>{setTransferBusy(value);onBusy(value);}} onChanged={()=>setRefresh(n=>n+1)}/>}{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {CustomerExportFiles} from './CustomerExportFiles';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError} from './api';
|
||||
type ExportField={id:number;key:string;label:string;type:string};
|
||||
type Rule={field:number;length:number|null;direction:string;sort:number;extra:string;noDelimiter:boolean;fill:string;format:number|null;condition:string;from:number|null;to:number|null;leftRight:boolean};
|
||||
type Settings={rules:Rule[];lineStart:string;lineEnd:string;fileStart:string;fileEnd:string;headline:string};
|
||||
type View={category:number;profile:number;revision:string;name:string;extension:string;fields:ExportField[];profiles:{id:number;label:string}[];settings:Settings;warning:string;includesGroupCustomers:boolean};
|
||||
type Options={categories:{id:number;label:string}[];from:string;to:string;vehicles?:{id:number;label:string}[]};
|
||||
type Result={encoding?:string;base64?:string;revision?:string;skipped?:number;removedVehicles?:number;filename:string;rows:number;bytes:number;content:string;truncated:boolean};
|
||||
|
||||
const number=(s:string)=>s===''?null:Number(s);
|
||||
const dateFormats:Record<number,string>={2:'Uhrzeit mit Sekunden',3:'Datum und Uhrzeit',4:'TT.MM.JJJJ',5:'TT.MM.JJ',6:'JJ.MM.TT',7:'JJMMTT',8:'JJJJ.MM.TT',9:'JJJJMMTT',10:'HH:MM',11:'MM/JJ',12:'JJJJMMTTHHMMSS',13:'Uhrzeit (Datum, kurzes Jahr)',14:'Uhrzeit (Datum, volles Jahr)'};
|
||||
export function CustomerExports({onClose,central=false}:{onClose:()=>void;central?:boolean}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=central?'/data-transfer/master-exports':'/customer-portal/exports';
|
||||
const [status,setStatus]=useState(3),[markExported,setMarkExported]=useState(true);
|
||||
const [customer,setCustomer]=useState(''),[job,setJob]=useState(''),[payment,setPayment]=useState(''),[vehicles,setVehicles]=useState<number[]>([]);
|
||||
const [confirmExport,exportDialog]=useDiscardConfirmation({title:'Export abschließen',confirm:'Export speichern',cancel:'Abbrechen'});
|
||||
const [options,setOptions]=useState<Options|null>(null),[view,setView]=useState<View|null>(null),[config,setConfig]=useState<Settings|null>(null),[name,setName]=useState(''),[extension,setExtension]=useState('txt'),[selected,setSelected]=useState(0),[search,setSearch]=useState('');
|
||||
const [from,setFrom]=useState(''),[to,setTo]=useState(''),[filename,setFilename]=useState('Kundenaufträge.txt'),[delimiter,setDelimiter]=useState(central?'|':';'),[headline,setHeadline]=useState(!central),[result,setResult]=useState<Result|null>(null);
|
||||
const [busy,setBusy]=useState(false),[fileBusy,setFileBusy]=useState(false),[dirty,setDirty]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[canStore,setCanStore]=useState(false),[storageUncertain,setStorageUncertain]=useState(false),[storageVersion,setStorageVersion]=useState(0),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const pendingStore=useRef<Record<string,unknown>|null>(null);
|
||||
const pending=useRef<Record<string,unknown>|null>(null),sending=useRef(false),[discard,dialog]=useDiscardConfirmation();
|
||||
const locked=busy||fileBusy||uncertain||storageUncertain||stale;
|
||||
const accounting=central&&(view?.category??0)>=4;
|
||||
function accept(v:View){if(view?.category!==v.category)setStatus(v.category>=4?0:3);setView(v);setConfig(v.settings);setName(v.name);setExtension(v.extension||'txt');setFilename(`${central?(v.category>=4?'Abrechnung':'Stammdaten'):'Kundenaufträge'}.${v.extension||'txt'}`);setSelected(v.settings.rules[0]?.field??v.fields[0]?.id??0);setDirty(false);setResult(null);setStale(false);pending.current=null;setSearch('');}
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);request<Options>(base,{signal:c.signal}).then(async o=>{if(c.signal.aborted)return;setOptions(o);setFrom(o.from||'');setTo(o.to||'');if(o.categories.length){const v=await request<View>(`${base}/${o.categories[0].id}/0`,{signal:c.signal});if(!c.signal.aborted)accept(v);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[]);
|
||||
async function close(){if(busy||fileBusy||uncertain||storageUncertain||sending.current)return;if(!dirty||await discard('Ungespeicherte Exportprofiländerungen verwerfen?'))onClose();}
|
||||
async function load(category:number,profile:number){if(locked||dirty&&!(await discard('Ungespeicherte Exportprofiländerungen verwerfen?')))return;setBusy(true);setError('');setNotice('');try{accept(await request<View>(`${base}/${category}/${profile}`));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
function invalidate(){setDirty(true);setError('');setNotice('');setResult(null);pending.current=null;}
|
||||
function update(p:Partial<Settings>){if(config){invalidate();setConfig({...config,...p});}}
|
||||
function ruleUpdate(p:Partial<Rule>){if(config)update({rules:config.rules.map(r=>r.field===selected?{...r,...p}:r)});}
|
||||
const missing=config?.rules.filter(r=>!view?.fields.some(f=>f.id===r.field))??[];
|
||||
const ordered=config?.rules.slice().sort((a,b)=>a.sort-b.sort)??[];
|
||||
const rule=config?.rules.find(r=>r.field===selected),field=view?.fields.find(f=>f.id===selected);
|
||||
function toggle(f:ExportField){if(!config)return;let rules=ordered.filter(r=>r.field!==f.id);if(rules.length===ordered.length)rules.push({field:f.id,length:null,direction:'l',sort:rules.length,extra:'',noDelimiter:false,fill:' ',format:null,condition:'',from:null,to:null,leftRight:false});update({rules:rules.map((r,i)=>({...r,sort:i}))});setSelected(f.id);}
|
||||
function move(delta:number){const i=ordered.findIndex(r=>r.field===selected),j=i+delta;if(!config||i<0||j<0||j>=ordered.length)return;const rules=[...ordered];[rules[i],rules[j]]=[rules[j],rules[i]];update({rules:rules.map((r,n)=>({...r,sort:n}))});}
|
||||
function command(){return {category:view!.category,profile:view!.profile,revision:view!.revision,settings:config,from:from||null,to:to||null,delimiter,headline,filename,...(central?{status,dataRevision:result?.revision,filter:accounting?{customer,job:number(job),payment:number(payment),vehicles}:null}: {})};}
|
||||
async function exportFile(download:boolean){if(!view||!config||locked||sending.current)return;sending.current=true;setBusy(true);setError('');try{const r=await post<Result>(`${base}/${download?'download':'preview'}`,command());setResult({...r,content:r.content.slice(0,20000),truncated:r.truncated||r.content.length>20000});if(download){const url=URL.createObjectURL(new Blob([r.base64?Uint8Array.from(atob(r.base64),c=>c.charCodeAt(0)):r.content],{type:r.base64?'application/octet-stream':'text/plain;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=r.filename;a.click();URL.revokeObjectURL(url);setNotice(`Datei mit ${r.rows} Datenzeilen heruntergeladen.`);}}catch(e){setError((e as Error).message);setStale(e instanceof ApiError&&e.status===409);}finally{sending.current=false;setBusy(false);}}
|
||||
async function storeFile(){if(!view||!config||sending.current||busy||fileBusy||uncertain||stale)return;if(central&&!pendingStore.current&&!(await confirmExport(accounting?(markExported?`Exportdatei speichern, ${result?.rows??0} Datenzeilen archivieren und die enthaltenen Aufträge als exportiert kennzeichnen?`:'Exportdatei speichern? Die Aufträge bleiben für einen späteren Abrechnungsexport verfügbar.'):markExported&&status!==0?`Exportdatei speichern und Änderungskennzeichen von ${result?.rows??0} Datensätzen zurücksetzen? ${result?.removedVehicles?`${result.removedVehicles} zur Löschung vorgemerkte Fahrzeuge werden nach erfolgreicher Dateispeicherung entfernt.`:''}`:'Exportdatei speichern? Die Änderungskennzeichen bleiben erhalten.')))return;sending.current=true;setBusy(true);setError('');pendingStore.current??={requestId:crypto.randomUUID(),run:command(),...(central?{markExported,confirmed:true}: {})};try{await post(central?'/data-transfer/master-export-runs':'/customer-portal/export-storage',pendingStore.current);pendingStore.current=null;setStorageUncertain(false);setStorageVersion(v=>v+1);setNotice('Exportdatei auf dem Server gespeichert.');if(central)setResult(null);}catch(e){setError((e as Error).message);const unclear=!(e instanceof ApiError)||e.status===0||e.status>=500;setStorageUncertain(unclear);if(!unclear){pendingStore.current=null;setStorageVersion(v=>v+1);}setStale(e instanceof ApiError&&e.status===409);}finally{sending.current=false;setBusy(false);}}
|
||||
async function save(){if(!view||!config||sending.current||stale)return;sending.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),category:view.category,profile:view.profile,revision:view.revision,name,extension,settings:config};try{const saved=await post<{profile:number}>(base+'/save',pending.current);setUncertain(false);setDirty(false);pending.current=null;setNotice('Exportprofil gespeichert.');try{accept(await request<View>(`${base}/${view.category}/${saved.profile}`));}catch(e){setStale(true);setError('Das Profil wurde gespeichert. Bitte schließen und neu öffnen. '+(e as Error).message);}}catch(e){setError((e as Error).message);setUncertain(!(e instanceof ApiError)||e.status===0||e.status>=500);setStale(e instanceof ApiError&&e.status===409);}finally{sending.current=false;setBusy(false);}}
|
||||
async function copy(){if(!view||locked)return;setBusy(true);setError('');try{const fresh=await request<View>(`${base}/${view.category}/0`);setView(fresh);setName(name?`${name} Kopie`.slice(0,100):'');setDirty(true);setResult(null);pending.current=null;}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
return <><Modal title={central?"Stammdaten und Abrechnung exportieren":"Kundenexport"} wide onClose={()=>void close()}><div className="modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{busy&&!view&&<p>Exportprofile werden geladen …</p>}{options&&!options.categories.length&&<p>Für diesen Kundenzugang sind keine Exportkategorien eingerichtet.</p>}
|
||||
{options&&view&&config&&<><fieldset disabled={locked}><div className="admin-toolbar"><Field label="Exportkategorie"><select value={view.category} onChange={e=>void load(Number(e.target.value),0)}>{options.categories.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Gespeichertes Profil"><select value={view.profile} onChange={e=>void load(view.category,Number(e.target.value))}><option value={0}>Neues Profil / alle Felder</option>{view.profiles.map(p=><option value={p.id} key={p.id}>{p.label}</option>)}</select></Field>{view.profile>0&&<button className="button" onClick={()=>void copy()}>Als neues Profil bearbeiten</button>}</div>
|
||||
{view.warning&&<p className="alert info">{view.warning}</p>}{accounting?<p>Erledigte, vollständige und noch nicht exportierte Aufträge der Verkaufsniederlassung. Bei einer einzelnen Auftragsnummer wird auch die eigene Erfassungsniederlassung berücksichtigt. Vorschau und Download verändern keine Aufträge.</p>:central?<p>Eigene Niederlassung · Vertrags- und Exportfristen werden berücksichtigt. Vorschau und direkter Download verändern keine Stammdaten.</p>:<p>Erledigte Aufträge nach Erledigungsdatum · Eigene Kostenstellen und Unterkostenstellen{view.includesGroupCustomers?' sowie zugeordnete Gruppenkunden':''} · Kostenfreie Stornos und Gutschriften ausgeschlossen</p>}
|
||||
<form onSubmit={e=>{e.preventDefault();void exportFile(false);}}><div className={accounting?"admin-toolbar accounting-export-filter":central?"admin-toolbar master-export-filter":"admin-toolbar"}>{central&&!accounting?<Field label="Änderungsfilter"><select value={status} onChange={e=>{setStatus(Number(e.target.value));setResult(null);}}><option value={3}>Neu und geändert / vorgemerkt</option><option value={1}>Nur neue Datensätze</option><option value={2}>Nur geänderte Datensätze</option><option value={0}>Alle Datensätze</option></select></Field>:<><Field label="Erledigt von"><input type="date" required={!accounting} value={from} onChange={e=>{setFrom(e.target.value);setResult(null);}}/></Field><Field label="Erledigt bis"><input type="date" required value={to} onChange={e=>{setTo(e.target.value);setResult(null);}}/></Field></>}{accounting&&<><Field label="Auftragsart"><select value={status} disabled={!!customer} onChange={e=>{setStatus(Number(e.target.value));setResult(null);}}><option value={0}>Bar- und Rechnungsaufträge</option><option value={1}>Nur Baraufträge</option><option value={2}>Nur Rechnungsaufträge</option></select></Field><Field label="Kundennummer (EID)"><input maxLength={20} value={customer} onChange={e=>{setCustomer(e.target.value);if(e.target.value)setStatus(2);else setJob('');setResult(null);}}/></Field><Field label="Einzelne Auftragsnummer"><input type="number" min={1} disabled={!customer} value={job} onChange={e=>{setJob(e.target.value);setResult(null);}}/></Field><Field label="Zahlart (optional)"><input type="number" min={0} max={9999} disabled={view.category===4} placeholder="Standard: 0, 1, 2" value={payment} onChange={e=>{setPayment(e.target.value);setResult(null);}}/></Field></>}<Field label="Dateiname"><input required maxLength={100} value={filename} onChange={e=>{setFilename(e.target.value);setResult(null);}}/></Field><Field label="Trennzeichen"><input required maxLength={1} value={delimiter} style={{width:70}} onChange={e=>{setDelimiter(e.target.value);setResult(null);}}/></Field><label className="admin-check"><input type="checkbox" checked={headline} onChange={e=>{setHeadline(e.target.checked);setResult(null);}}/>Kopfzeile ausgeben</label><button className="button primary" disabled={!config.rules.length}>Exportvorschau</button></div>{accounting&&<fieldset className="accounting-export-vehicles"><legend>Fahrzeugarten des Transporteurs · keine Auswahl = alle</legend>{options?.vehicles?.map(v=><label className="admin-check" key={v.id}><input type="checkbox" checked={vehicles.includes(v.id)} onChange={e=>{setVehicles(e.target.checked?[...vehicles,v.id]:vehicles.filter(id=>id!==v.id));setResult(null);}}/>{v.label}</label>)}</fieldset>}</form>
|
||||
{missing.length>0&&<p className="alert info">{missing.length} alte Feldnummern sind nicht mehr definiert: {missing.map(r=>r.field).join(', ')}. <button className="button" onClick={()=>update({rules:ordered.filter(r=>view.fields.some(f=>f.id===r.field)).map((r,sort)=>({...r,sort}))})}>Unbekannte Felder aus dem Entwurf entfernen</button></p>}
|
||||
<details><summary>Profil bearbeiten · {config.rules.length} {uiText(" von ")}{view.fields.length} Feldern ausgewählt</summary><div className="admin-toolbar"><Field label="Profilname"><input maxLength={100} value={name} onChange={e=>{invalidate();setName(e.target.value);}}/></Field><Field label="Dateiendung"><input maxLength={10} value={extension} onChange={e=>{invalidate();setExtension(e.target.value);}}/></Field><button className="button" disabled={!dirty||!name.trim()||!config.rules.length} onClick={()=>void save()}>Profil speichern</button></div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'minmax(240px,1fr) minmax(280px,2fr)',gap:20}}><div><Field label="Exportfelder suchen"><input value={search} onChange={e=>setSearch(e.target.value)}/></Field><div className="admin-toolbar"><button className="button" onClick={()=>update({rules:[]})}>Keine auswählen</button><button className="button" disabled={!rule||ordered[0]?.field===selected} onClick={()=>move(-1)}>Nach oben</button><button className="button" disabled={!rule||ordered.at(-1)?.field===selected} onClick={()=>move(1)}>Nach unten</button></div><div style={{maxHeight:340,overflow:'auto'}}>{[...view.fields].sort((a,b)=>(config.rules.find(r=>r.field===a.id)?.sort??1000)-(config.rules.find(r=>r.field===b.id)?.sort??1000)).filter(f=>`${f.label} ${f.key}`.toLowerCase().includes(search.toLowerCase())).map(f=><div key={f.id} style={{display:'flex',gap:8,alignItems:'center',padding:4,background:f.id===selected?'var(--surface-hover,#eef2ff)':undefined}}><input type="checkbox" aria-label={`${f.label} exportieren`} checked={config.rules.some(r=>r.field===f.id)} onChange={()=>toggle(f)}/><button className="button" style={{textAlign:'left',flex:1}} onClick={()=>setSelected(f.id)}>{config.rules.find(r=>r.field===f.id)?.sort!==undefined?`${(config.rules.find(r=>r.field===f.id)?.sort??0)+1}. `:''}{f.label}</button></div>)}</div></div>
|
||||
<div>{field&&<h3>{field.label}</h3>}{rule&&field?<><div className="form-grid"><Field label="Feste Länge (leer: vollständig)"><input type="number" min={0} max={10000} value={rule.length??''} onChange={e=>ruleUpdate({length:number(e.target.value)})}/></Field><Field label="Ausrichtung"><select value={rule.direction} onChange={e=>ruleUpdate({direction:e.target.value})}><option value="l">Links</option><option value="r">Rechts</option><option value="">Standard (rechts)</option></select></Field><Field label="Füllzeichen"><input maxLength={1} value={rule.fill} onChange={e=>ruleUpdate({fill:e.target.value})}/></Field>{['float','datetime'].includes(field.type)&&<Field label="Ausgabeformat"><select value={rule.format??''} onChange={e=>ruleUpdate({format:number(e.target.value)})}><option value="">Originalwert</option>{Object.entries(field.type==='float'?{1:'12.34',2:'12,34',3:'1234 (Cent)'}:dateFormats).map(([value,label])=><option value={value} key={value}>{label}</option>)}</select></Field>}<Field label={rule.leftRight?'Zeichen von links':'Teiltext ab Position (0-basiert)'}><input type="number" min={0} max={10000} value={rule.from??''} onChange={e=>ruleUpdate({from:number(e.target.value)})}/></Field><Field label={rule.leftRight?'Zeichen von rechts':'Teiltext bis Position (einschließlich)'}><input type="number" min={0} max={10000} value={rule.to??''} onChange={e=>ruleUpdate({to:number(e.target.value)})}/></Field></div><label className="admin-check"><input type="checkbox" checked={rule.leftRight} onChange={e=>ruleUpdate({leftRight:e.target.checked})}/>Linken und rechten Teil verbinden</label><label className="admin-check"><input type="checkbox" checked={rule.noDelimiter} onChange={e=>ruleUpdate({noDelimiter:e.target.checked})}/>Ohne Trennzeichen verbinden</label><Field label="Zusatztext / Platzhalter"><input maxLength={4000} value={rule.extra} onChange={e=>ruleUpdate({extra:e.target.value})}/></Field><Field label="Bedingung (Feldnummer=Wert, mehrere mit /)"><input maxLength={1000} value={rule.condition} onChange={e=>ruleUpdate({condition:e.target.value})}/></Field><p className="form-hint">Ohne Trennzeichen und ohne Zusatztext werden aufeinanderfolgende Felder mit Leerzeichen in ihrer gemeinsamen Breite zusammengeführt.</p></>:<p>Dieses Feld zuerst für den Export auswählen.</p>}</div></div>
|
||||
<div className="form-grid">{(['headline','lineStart','lineEnd','fileStart','fileEnd'] as const).map(k=><Field key={k} label={{headline:'Eigene Kopfzeile',lineStart:'Zeilenanfang',lineEnd:'Zeilenende',fileStart:'Dateianfang',fileEnd:'Dateiende'}[k]}><textarea maxLength={['lineStart','lineEnd','fileEnd'].includes(k)?150:10000} value={config[k]} onChange={e=>update({[k]:e.target.value})}/></Field>)}</div>
|
||||
<details><summary>Platzhalter und Feldnummern</summary><p>Zusatztexte und Zeilenanfang/-ende unterstützen beispielsweise {'<dbfield>0</dbfield>'}, {'<dbfieldsub>0,0,5</dbfieldsub>'}, {'<date,9><dbfield>0</dbfield></date>'}, {'<iter,3>x</iter>'} und {'<lfd,5>Nr.</lfd>'}. Der Zähler beginnt bei 0.</p><p>Feldnummern sind fest und unabhängig von der Ausgabereihenfolge:</p><div style={{maxHeight:180,overflow:'auto'}}>{view.fields.map((f,i)=><div key={f.id}>{i}: {f.label}</div>)}</div></details>
|
||||
</details></fieldset>
|
||||
{result&&<section><h3>Exportvorschau</h3>{central&&!accounting&&<p>{result.skipped??0} Datensätze wegen Vertrags-/Exportfristen ausgelassen · {result.removedVehicles??0} Fahrzeuge zur Löschung vorgemerkt</p>}<p>{result.rows} Datenzeilen · {result.bytes.toLocaleString('de-DE')} Bytes · {result.encoding||'UTF-8'}{result.truncated?' · Vorschau gekürzt, Download vollständig':''}</p><pre style={{maxHeight:300,overflow:'auto',padding:12,background:'var(--surface-hover,#f4f5f7)'}}>{result.content||'Keine Datenzeilen im gewählten Zeitraum.'}</pre><button className="button primary" disabled={locked} onClick={()=>void exportFile(true)}>Datei herunterladen</button>{central&&<label className="admin-check"><input type="checkbox" disabled={locked||!accounting&&status===0} checked={markExported&&(accounting||status!==0)} onChange={e=>setMarkExported(e.target.checked)}/>Nach Dateispeicherung als exportiert kennzeichnen{!accounting&&status===0?" (bei Alle deaktiviert)":""}</label>}{canStore&&<button className="button" disabled={locked||result.rows===0} onClick={()=>void storeFile()}>Auf Server speichern</button>}</section>}</>}
|
||||
<CustomerExportFiles paths={central?{files:"/data-transfer/master-export-files",storage:"/data-transfer/master-export-runs",removal:"/data-transfer/master-export-files/removal"}:undefined} disabled={busy||uncertain||storageUncertain||stale} onBusy={setFileBusy} onAvailability={setCanStore} version={storageVersion}/>
|
||||
{uncertain&&<p>Die Speicherbestätigung fehlt. Bitte dasselbe Ergebnis erneut abrufen.</p>}{storageUncertain&&<p>Die Bestätigung der Dateispeicherung fehlt. Bitte denselben Vorgang erneut abrufen; offene Vorgänge bleiben auch nach dem erneuten Öffnen verfügbar.</p>}{stale&&<p>Der Stand hat sich geändert. Bitte schließen und neu öffnen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||fileBusy||uncertain||storageUncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{storageUncertain&&<button className="button primary" disabled={busy} onClick={()=>void storeFile()}>Dateispeicherung erneut abrufen</button>}{uncertain&&<button className="button primary" disabled={busy} onClick={()=>void save()}>Speicherergebnis erneut abrufen</button>}</div></Modal>{dialog}{exportDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {useEffect,useState,type ReactNode} from 'react';
|
||||
import {request} from './api';
|
||||
import {Lookup} from './EntryLookup';
|
||||
import {Modal} from './FormElements';
|
||||
import {MasterDataEditor} from './BusinessForms';
|
||||
import {CustomerCourierRelations} from './CustomerCourierRelations';
|
||||
import {OrderWorkbench} from './OrderWorkbench';
|
||||
import {AppointmentEditor,type Appointment} from './AppointmentForm';
|
||||
import {ReportDetails} from './Reports';
|
||||
import {customerHistorySeries} from './customerHistory.mjs';
|
||||
|
||||
type Option={id:number;label:string};
|
||||
type Section<T>={available:boolean;message?:string}&Partial<T>;
|
||||
type History={month:string;orders:number;amount:number};
|
||||
type Report={id:number;created:string;text:string;confidential:number;type:number;typeLabel:string;author:string};
|
||||
type Visit={id:number;startsAt:string;endsAt:string;text:string;kind:number;kindLabel:string;author:string};
|
||||
type Data={customer:number;headquarters:number;label:string;asOf:string;profile:Record<string,string>;previousLogin:boolean;loginAvailable:boolean;
|
||||
centers:Section<{rows:{id:number;label:string;visible:number;blocked:number}[];truncated:boolean}>;
|
||||
drivers:Section<{rows:{id:number;relation:number;position:number;description:string;number:string;vehicle:string;vehicles:string[];name:string;company:string;street:string;postal:string;city:string;country:string}[];truncated:boolean}>;
|
||||
statistics:Section<{years:{year:number;orders:number;amount:number}[];months:History[];relatedCustomer:boolean;excludeFreeCancellations:boolean}>;
|
||||
lastOrder:Section<{item:{id:number;ordered:string;status:number}|null}>;
|
||||
sinceReports:Section<{from:string;to:string;total:number;rows:Report[]}>;yearReports:Section<{from:string;to:string;total:number;rows:Report[]}>;
|
||||
appointments:Section<{from:string;total:number;rows:Visit[]}>};
|
||||
const money=(n:number)=>Number(n).toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string|undefined)=>s&&!s.startsWith('0000-')?new Date(s).toLocaleDateString('de-DE'):'—';
|
||||
const time=(s:string|undefined)=>s?new Date(s).toLocaleString('de-DE',{dateStyle:'medium',timeStyle:'short'}):'—';
|
||||
function Block({title,data,children}:{title:string;data:{available:boolean;message?:string};children:ReactNode}){return <section className="customer-file-block"><h3>{title}</h3>{data.available?children:<p role="status">{data.message||'Dieser Bereich ist nicht verfügbar.'}</p>}</section>;}
|
||||
function HistoryChart({months,metric}:{months:History[];metric:'orders'|'amount'}){
|
||||
const series=customerHistorySeries(months,metric),title=metric==='orders'?'Aufträge pro Monat':'Nettoumsatz pro Monat';
|
||||
return <figure className="customer-file-chart"><figcaption>{title}</figcaption><svg viewBox="0 0 720 200" role="img" aria-label={title}><title>{title}</title><desc>36 Kalendermonate. Die genauen Werte stehen in der Tabelle unter den Diagrammen.</desc><line x1="40" x2="700" y1={series.zero} y2={series.zero} stroke="currentColor" opacity=".3"/><polyline fill="none" stroke="var(--primary, #4d3f96)" strokeWidth="2.5" points={series.points.map(p=>`${p.x},${p.y}`).join(' ')}/>{series.points.map(p=><circle key={p.month} cx={p.x} cy={p.y} r="3" fill="var(--primary, #4d3f96)"><title>{p.month}: {metric==='amount'?money(p.value):p.value}</title></circle>)}<text x="40" y="194">{months[0]?.month}</text><text x="700" y="194" textAnchor="end">{months.at(-1)?.month}</text><text x="40" y="13">{metric==='amount'?money(series.max):series.max.toLocaleString('de-DE')}</text></svg></figure>;
|
||||
}
|
||||
function AppointmentDetails({id,onClose,onSaved}:{id:number;onClose:()=>void;onSaved:()=>void}){
|
||||
const [item,setItem]=useState<Appointment|null>(null),[error,setError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();request<Appointment>(`/appointments/${id}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setItem(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[id]);
|
||||
return item?<AppointmentEditor appointment={item} onClose={onClose} onSaved={onSaved} onDirtyChange={()=>{}}/>:<Modal title={`Termin ${id}`} onClose={onClose}><div className="admin-dialog-body">{error?<p role="alert">{error}</p>:<p>Termin wird geladen …</p>}</div></Modal>;
|
||||
}
|
||||
export function CustomerFile({onDirtyChange}:{onDirtyChange:(dirty:boolean)=>void}){
|
||||
const [selected,setSelected]=useState<Option|null>(null),[data,setData]=useState<Data|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[reload,setReload]=useState(0);
|
||||
const [master,setMaster]=useState<{kind:string;id:number}|null>(null),[relations,setRelations]=useState(false),[order,setOrder]=useState<number|null>(null),[appointment,setAppointment]=useState<number|null>(null),[report,setReport]=useState<number|null>(null);
|
||||
const modal=!!master||relations||order!==null||appointment!==null||report!==null;
|
||||
useEffect(()=>{onDirtyChange(modal);return()=>onDirtyChange(false);},[modal,onDirtyChange]);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');if(!selected){setBusy(false);return()=>c.abort();}setBusy(true);request<Data>(`/crm/customer-file/${selected.id}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[selected,reload]);
|
||||
function saved(){setMaster(null);setOrder(null);setAppointment(null);setReport(null);setReload(n=>n+1);}
|
||||
function reports(title:string,section:Data['sinceReports']){return <Block title={title} data={section}><p>{section.total} Berichte · {time(section.from)} bis {time(section.to)}</p>{section.rows?.map(r=><article className="customer-file-record" key={r.id}><button className="button" onClick={()=>setReport(r.id)}>Bericht {r.id} öffnen</button><small>{time(r.created)} · {r.author} · {r.typeLabel||(r.type?`Typ ${r.type}`:'Ohne Berichtstyp')}{r.confidential?' · Vertraulich':''}</small><p>{r.text}</p></article>)}{!section.rows?.length&&<p>Keine Berichte in diesem Zeitraum.</p>}{(section.total??0)>10&&<p>Die zehn neuesten Berichte werden angezeigt.</p>}</Block>;}
|
||||
const p=data?.profile;
|
||||
return <div className="customer-file"><div className="admin-title"><div><h2>Kundenakte</h2><p>Firmendaten, Vorgänge und Entwicklung des Kunden.</p></div><button className="button" disabled={!selected||busy||modal} onClick={()=>setReload(n=>n+1)}>Kundenakte neu laden</button></div>
|
||||
<Lookup<Option> label="Kunde für Kundenakte" endpoint="/crm/customer-file/customers" value={selected} describe={v=>v.label} onSelect={setSelected} disabled={modal}/>
|
||||
{busy&&<p role="status">Kundenakte wird geladen …</p>}{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
{data&&p&&<><h2>{data.label}</h2><p className="form-hint">Stand {time(data.asOf)} · Eigene Niederlassung{data.loginAvailable&&!data.previousLogin?' · Kein vorheriger Login im laufenden Jahr: Berichte ab Jahresbeginn, Termine ab jetzt.':''}</p><div className="customer-file-grid">
|
||||
<section className="customer-file-block"><h3>Firmendaten</h3><dl>{[['Debitor',p.cs_eid],['Firma',[p.cmp_comp,p.cmp_comp2,p.cmp_comp3,p.cmp_comp4].filter(Boolean).join('\n')],['Adresse',[[p.ad_street,p.cmp_hsno].filter(Boolean).join(' '),[p.ad_zipcode,p.ad_city,p.ad_country].filter(Boolean).join(' ')].join('\n')],['Ansprechpartner',[p.usr_firstname,p.usr_name].filter(Boolean).join(' ')],['E-Mail',p.usr_email],['Telefon',p.usr_phone],['Mobil',p.usr_phone2],['Fax',p.usr_fax],['Kunde seit',date(p.cs_become_cs_date)],['Bank',p.cmp_bank],['Bankleitzahl',p.cmp_bankno],['Kontonummer',p.cmp_bankacc],['IBAN',p.cmp_iban],['BIC',p.cmp_swift]].map(([label,value])=><div key={label}><dt>{label}</dt><dd>{value||'—'}</dd></div>)}</dl><button className="button" onClick={()=>setMaster({kind:'customers',id:data.customer})}>Kundenstammdaten öffnen</button></section>
|
||||
<Block title="Kostenstellen" data={data.centers}>{data.centers.rows?.map(c=><p key={c.id}><button className="button" onClick={()=>setMaster({kind:'centers',id:c.id})}>{c.label||`Kostenstelle ${c.id}`}</button>{!c.visible&&' · Ausgeblendet'}{!!c.blocked&&' · Gesperrt'}</p>)}{!data.centers.rows?.length&&<p>Keine Kostenstellen vorhanden.</p>}{data.centers.truncated&&<p>Die ersten 2000 Kostenstellen werden angezeigt.</p>}</Block>
|
||||
<Block title="Fahrerbeziehungen" data={data.drivers}>{data.drivers.rows?.map((r,i)=><article className="customer-file-record" key={`${r.id}-${i}`}><strong>{r.relation===1?`Bevorzugt · Rang ${r.position}`:'Gesperrt'}</strong><p>{r.company}<br/>{r.name}<br/>{r.street}<br/>{[r.postal,r.city,r.country].filter(Boolean).join(' ')}</p><p>Fahrzeuge: {r.vehicles?.join(', ')||r.vehicle||'—'}</p>{r.description&&<p>{r.description}</p>}<button className="button" onClick={()=>setMaster({kind:'couriers',id:r.id})}>Transporteur {r.number} öffnen</button></article>)}{!data.drivers.rows?.length&&<p>Keine bevorzugten oder gesperrten Fahrer.</p>}{data.drivers.truncated&&<p>Die Anzeige ist begrenzt. Weitere Beziehungen finden Sie in der Pflege.</p>}<button className="button" onClick={()=>setRelations(true)}>Fahrerbeziehungen pflegen</button></Block>
|
||||
<Block title="Auftragsentwicklung" data={data.statistics}><table className="data-table"><thead><tr><th>Jahr</th><th>Aufträge</th><th>Netto</th></tr></thead><tbody>{data.statistics.years?.slice().reverse().map(y=><tr key={y.year}><th>{y.year}</th><td>{y.orders}</td><td>{money(y.amount)}</td></tr>)}</tbody></table><p>Erledigte Aufträge nach Erledigungsdatum. {data.statistics.relatedCustomer?'Zuordnung zum zugehörigen Kunden.':'Zuordnung zur bezahlenden Kostenstelle.'}{data.statistics.excludeFreeCancellations?' Kostenfreie Stornos ausgeschlossen.':''}</p></Block>
|
||||
<Block title="Letzter Auftrag" data={data.lastOrder}>{data.lastOrder.item?<><p>Auftrag {data.lastOrder.item.id} · {time(data.lastOrder.item.ordered)}</p><button className="button" onClick={()=>setOrder(data.lastOrder.item!.id)}>Auftrag öffnen</button></>:<p>Kein Auftrag vorhanden.</p>}</Block>
|
||||
{reports('Berichte seit vorigem Login',data.sinceReports)}{reports(`Berichte ${data.asOf.slice(0,4)}`,data.yearReports)}
|
||||
<Block title="Termine seit vorigem Login" data={data.appointments}><p>{data.appointments.total} Termine · ab {time(data.appointments.from)}</p>{data.appointments.rows?.map(a=><article className="customer-file-record" key={a.id}><button className="button" onClick={()=>setAppointment(a.id)}>Termin {a.id} öffnen</button><small>{time(a.startsAt)} · {a.author} · {a.kindLabel||(a.kind?`Terminart ${a.kind}`:'Ohne Terminart')}</small><p>{a.text}</p></article>)}{!data.appointments.rows?.length&&<p>Keine freigegebenen Termine in diesem Zeitraum.</p>}{(data.appointments.total??0)>10&&<p>Die zehn Termine mit dem spätesten Beginn werden angezeigt.</p>}</Block>
|
||||
</div>{data.statistics.available&&data.statistics.months&&<section className="customer-file-block"><h3>Monatliche Entwicklung</h3><div className="customer-file-charts"><HistoryChart months={data.statistics.months} metric="orders"/><HistoryChart months={data.statistics.months} metric="amount"/></div><details><summary>Alle 36 Monatswerte als Tabelle</summary><table className="data-table"><thead><tr><th>Monat</th><th>Aufträge</th><th>Netto</th></tr></thead><tbody>{data.statistics.months.map(m=><tr key={m.month}><th>{m.month}</th><td>{m.orders}</td><td>{money(m.amount)}</td></tr>)}</tbody></table></details></section>}</>}
|
||||
{master&&<MasterDataEditor {...master} parent={selected?.id??0} onClose={()=>setMaster(null)} onSaved={saved}/>}
|
||||
{relations&&selected&&<CustomerCourierRelations kind="customers" id={selected.id} onClose={()=>{setRelations(false);setReload(n=>n+1);}}/>}
|
||||
{order!==null&&<OrderWorkbench id={order} onClose={()=>setOrder(null)} onSaved={saved}/>}
|
||||
{appointment!==null&&<AppointmentDetails id={appointment} onClose={()=>setAppointment(null)} onSaved={saved}/>}
|
||||
{report!==null&&<ReportDetails kind="CUSTOMER" id={report} onClose={()=>setReport(null)} onSaved={saved}/>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
type View={center:number;label:string;companyDifferent:boolean;addressDifferent:boolean;company:string[];address:string;warning:string};
|
||||
export function CustomerInvoiceAddress({customer,revision,disabled,onOpen}:{customer:number;revision:string;disabled:boolean;onOpen:(center:number)=>void}){
|
||||
const [data,setData]=useState<View|null>(null),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');request<View>(`/master/customers/${customer}/invoice-address`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[customer,revision,reload]);
|
||||
if(data&&!data.companyDifferent&&!data.addressDifferent&&!data.warning)return null;
|
||||
return <section className="business-section" aria-label="Rechnungsanschrift der Hauptkostenstelle"><h3>Rechnungsanschrift der Hauptkostenstelle</h3>{!data&&!error&&<p>Gespeicherte Rechnungsanschrift wird geprüft …</p>}{error&&<><p className="alert error" role="alert">{error}</p><button type="button" className="button" disabled={disabled} onClick={()=>setReload(x=>x+1)}>Rechnungsanschrift erneut laden</button></>}{data&&<>{data.warning&&<p className="alert info" role="status">{data.warning}</p>}<div className="form-grid cols-2">{data.companyDifferent&&<section><h4>Abweichender Firmenname</h4>{data.company.some(x=>x.trim())?data.company.map((line,i)=>line&&<div key={i}>{line}</div>):<p>Kein Firmenname hinterlegt.</p>}</section>}{data.addressDifferent&&<section><h4>Abweichende Rechnungsanschrift</h4><p style={{whiteSpace:'pre-wrap'}}>{data.address}</p></section>}</div>{data.center>0&&<><p className="form-hint">Gespeicherter Stand der Hauptkostenstelle {data.label}. Änderungen am Kundenstamm werden erst durch die gesonderte Kostenstellenübernahme weitergegeben.</p><button type="button" className="button" disabled={disabled} onClick={()=>onOpen(data.center)}>Rechnungsanschrift bearbeiten</button>{disabled&&<p className="form-hint">Bitte Änderungen am Kundenstamm zuerst speichern oder verwerfen.</p>}</>}</>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type Values={language:number;arrivalTime:boolean;noFooter:boolean;extraFields:Record<string,boolean>;jamMail:boolean;jamMinutes:number};
|
||||
type View={customer:number;label:string;revision:string;values:Values;jamDefault:number;warnings:string[]};
|
||||
type Draft={revision:string;values:Values};
|
||||
type Preview={fingerprint:string;before:Values;after:Values;warnings:string[]};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
export const customerMailExtraLabels:Record<string,string>={'0':'Etappenbemerkung','2':'Rechnungstext','6':'KFZ-Kennzeichen','8':'Bemerkung Kundenmail Kurier','9':'Bemerkung Kundenmail Innendienst','3':'Änderungsmail nach Bearbeitung eines erledigten Auftrags anbieten','4':'Zusätzliche Mail nach Erledigung einer Station','7':'Mail nach Auftragsstorno'};
|
||||
function rows(v:Values):[string,string][]{return [['Sprache',v.language===1?'Englisch':'Deutsch'],['Ankunftszeit anzeigen',v.arrivalTime?'Ja':'Nein'],['Fußzeile ausblenden',v.noFooter?'Ja':'Nein'],...Object.entries(customerMailExtraLabels).map(([key,label]):[string,string]=>[label,v.extraFields[key]?'Ja':'Nein']),['Mail bei Vermittlungsstau',v.jamMail?'Ja':'Nein'],['Wartezeit',`${v.jamMinutes} Minuten`]];}
|
||||
export function CustomerMailSettings({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${customer}/mail-settings`;
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values|null>(null),[preview,setPreview]=useState<Preview|null>(null);
|
||||
const [busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
const pending=useRef<Command|null>(null),saving=useRef(false);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setValues(null);setPreview(null);setError('');pending.current=null;
|
||||
void request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setView(v);setValues(v.values);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,reload]);
|
||||
const dirty=!!view&&!!values&&!done&&JSON.stringify(values)!==JSON.stringify(view.values);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(!dirty||await discard('Ungespeicherte Kunden-Mailvorgaben verwerfen?'))onClose();}
|
||||
async function refresh(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Kunden-Mailvorgaben verwerfen und neu laden?'))setReload(n=>n+1);}
|
||||
function change(patch:Partial<Values>){setValues(v=>v?{...v,...patch}:v);setPreview(null);pending.current=null;setError('');}
|
||||
function draft():Draft{if(!view||!values)throw Error('Bitte zuerst die Kundenvorgaben laden.');return {revision:view.revision,values};}
|
||||
async function check(){if(busy||uncertain)return;setBusy(true);setPreview(null);setError('');try{setPreview(await post<Preview>(`${base}/preview`,draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};
|
||||
try{await post(base,pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){setPreview(null);pending.current=null;}setError((e as Error).message);}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Kunden-Mailvorgaben" wide onClose={()=>void close()}><div className="modal-body customer-mail-settings">
|
||||
{view&&<h3>{view.label} · #{customer}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Mailvorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Kunden-Mailvorgaben gespeichert. Für einen Versand bitte eine neue Mailvorschau erstellen.</p>:view&&values&&<>
|
||||
<fieldset disabled={busy||uncertain}><div className="form-grid cols-2"><Field label="Verwendete Sprache in den Mails"><select value={values.language} onChange={e=>change({language:Number(e.target.value)})}><option value={0}>{uiText("Deutsch")}</option><option value={1}>{uiText("Englisch")}</option></select></Field><Field label="Wartezeit bei Vermittlungsstau"><select value={values.jamMinutes} onChange={e=>change({jamMinutes:Number(e.target.value)})}>{Array.from({length:13},(_,i)=><option key={i} value={i*5}>{i*5} Minuten</option>)}</select></Field></div>
|
||||
<div className="form-grid cols-2">{(['arrivalTime','noFooter','jamMail'] as const).map((key,i)=><label className="admin-check" key={key}><input type="checkbox" checked={values[key]} onChange={e=>change({[key]:e.target.checked})}/>{['Ankunftszeit anzeigen','Fußzeile ausblenden','Mail bei Vermittlungsstau'][i]}</label>)}{Object.entries(customerMailExtraLabels).map(([key,label])=><label className="admin-check" key={key}><input type="checkbox" checked={values.extraFields[key]} onChange={e=>change({extraFields:{...values.extraFields,[key]:e.target.checked}})}/>{label}</label>)}</div>
|
||||
</fieldset><p className="form-hint">Bei 0 Minuten gilt die allgemeine Standardwartezeit. Empfänger, Erfassungs-/Abhol-/Erledigungsmail, Preis-, Mitarbeiter- und PDF-Ausgabe stehen im Stammblatt.</p>
|
||||
{view.warnings.map((w,i)=><p className="form-hint" key={i}>{w}</p>)}
|
||||
{preview&&<section className="customer-settings-preview"><h3>Geprüfte Änderung</h3><div className="table-wrap"><table><thead><tr><th>Angabe</th><th>Bisher</th><th>Nach dem Speichern</th></tr></thead><tbody>{rows(preview.before).map(([label,value],i)=><tr key={label}><th>{label}</th><td>{value}</td><td>{rows(preview.after)[i][1]}</td></tr>)}</tbody></table></div></section>}
|
||||
{uncertain&&<p role="status" className="alert info">Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen; die Änderung wird nicht doppelt ausgeführt.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void refresh()}>Neu laden</button>}{view&&!done&&(preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Mailvorgaben speichern'}</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useMemo,useState} from 'react';
|
||||
import {Modal,Field} from './FormElements';
|
||||
import {ApiError,request} from './api';
|
||||
import {MapCanvas} from './MapCanvas';
|
||||
type Point={latitude:number;longitude:number};
|
||||
type Vehicle={sid:string;vehicle:string;phone:string;locatedAt:string;source:string;ageSeconds:number;stale:boolean;point:Point|null;warning:string};
|
||||
type Snapshot={headquarters:string;center:Point|null;at:string;tiles:string;attribution:string;items:Vehicle[];configured:number;message:string};
|
||||
const date=(s:string)=>new Date(s).toLocaleString('de-DE');
|
||||
export function CustomerMap({onClose}:{onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<Snapshot|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[automatic,setAutomatic]=useState(true),[refresh,setRefresh]=useState(0),[query,setQuery]=useState(''),[selected,setSelected]=useState(''),[group,setGroup]=useState<string[]>([]),[fit,setFit]=useState(0),[focus,setFocus]=useState<{key:string;point:Point;nonce:number}|null>(null);
|
||||
useEffect(()=>{const abort=new AbortController();setBusy(true);request<Snapshot>('/customer-portal/map',{signal:abort.signal}).then(d=>{if(!abort.signal.aborted){setData(d);setError('');}}).catch(e=>{if(!abort.signal.aborted){setData(null);setError(e.message);setGroup([]);setSelected('');if(e instanceof ApiError&&(e.status===401||e.status===403))setAutomatic(false);}}).finally(()=>{if(!abort.signal.aborted)setBusy(false);});return()=>abort.abort();},[refresh]);
|
||||
useEffect(()=>{if(!automatic||busy)return;const timer=window.setTimeout(()=>setRefresh(n=>n+1),10000);return()=>clearTimeout(timer);},[automatic,busy,refresh]);
|
||||
const filtered=useMemo(()=>data?.items.filter(v=>(v.sid+' '+v.vehicle).toLowerCase().includes(query.toLowerCase().trim()))||[],[data,query]);
|
||||
const markers=useMemo(()=>[...(data?.center?[{key:'hq',label:'Zentrale',point:data.center,color:'#695596',stale:false}]:[]),...filtered.filter(v=>v.point).map(v=>({key:v.sid,label:v.sid,point:v.point!,color:v.stale?'#64748b':'#2563eb',stale:v.stale}))],[data,filtered]);
|
||||
const current=data?.items.find(v=>v.sid===selected);
|
||||
function choose(key:string){setSelected(key);setGroup([]);const marker=markers.find(m=>m.key===key);if(marker)setFocus({key,point:marker.point,nonce:Date.now()});}
|
||||
return <Modal title="Kartenübersicht" wide onClose={onClose}><div className="modal-body">
|
||||
<div className="admin-toolbar"><label className="admin-check"><input type="checkbox" checked={automatic} onChange={e=>setAutomatic(e.target.checked)}/>Automatisch alle 10 Sekunden aktualisieren</label><button className="button" disabled={busy} onClick={()=>setRefresh(n=>n+1)}>{busy?'Wird aktualisiert …':'Jetzt aktualisieren'}</button><button className="button" disabled={!data} onClick={()=>setFit(n=>n+1)}>Alle Positionen zeigen</button></div>
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{!data&&!error&&<p>Kartenübersicht wird geladen …</p>}
|
||||
{data&&<><p>{data.headquarters} · Stand {date(data.at)} · {data.configured} freigegebene Fahrzeuge</p>{data.message&&<p role="status" className="alert info">{data.message}</p>}<Field label="Fahrzeug suchen"><input value={query} onChange={e=>setQuery(e.target.value)} placeholder="Kennung oder Fahrzeugtyp"/></Field>
|
||||
<MapCanvas markers={markers} selected={selected} focus={focus} fit={fit} center={data.center} tiles={data.tiles} attribution={data.attribution} onSelect={choose} onGroup={setGroup}/>
|
||||
<p className="form-hint">Ortungen seit Tagesbeginn. Graue Markierungen kennzeichnen veraltete Positionen.</p>
|
||||
{group.length>0&&<section><h3>Positionen an diesem Kartenpunkt</h3><div className="admin-toolbar">{group.filter(k=>markers.some(m=>m.key===k)).map(key=><button key={key} className="button" onClick={()=>choose(key)}>{key==='hq'?uiText("Zentrale"):key}</button>)}</div></section>}
|
||||
{current&&<section aria-live="polite"><h3>{current.sid} · {current.vehicle}</h3><p>{uiText("Telefon: ")}{current.phone||'Keine Telefonnummer hinterlegt'}</p><p>Letzte Ortung: {date(current.locatedAt)} · {current.source}</p>{current.warning&&<p className="alert info">{current.warning}</p>}</section>}
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Fahrzeug")}</th><th>{uiText("Telefon")}</th><th>Letzte Ortung</th><th>Hinweis</th></tr></thead><tbody>{filtered.map(v=><tr key={v.sid}><td><button className="text-button" onClick={()=>choose(v.sid)}>{v.sid} · {v.vehicle}</button></td><td>{v.phone||'–'}</td><td>{date(v.locatedAt)}<small>{v.source} · {Math.floor(v.ageSeconds/60)} Min. alt</small></td><td>{v.warning||'Position vorhanden'}</td></tr>)}</tbody></table></div>{!filtered.length&&!data.message&&<p>Keine Fahrzeuge für diese Suche.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
import {CustomerCourierAssignment} from './CustomerCourierAssignment';
|
||||
import {CustomerOrderEntry} from './CustomerOrderEntry';
|
||||
import {CustomerOrderTexts} from './CustomerOrderTexts';
|
||||
import {CustomerCancellation} from './CustomerCancellation';
|
||||
import {CustomerOrderRoute} from './CustomerOrderRoute';
|
||||
import {CustomerOrderHistory} from './CustomerOrderHistory';
|
||||
import {CustomerOrderLabels} from './CustomerOrderLabels';
|
||||
import {CustomerOrderEvidence} from './CustomerOrderEvidence';
|
||||
import {CustomerOrderExtras} from './CustomerOrderExtras';
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string|null)=>s?new Date(s).toLocaleString('de-DE'):'–';
|
||||
export function CustomerOrder({job,onClose,endpoint="/customer-portal/detail",payload,onChanged,onCopy}:{job:number;onClose:()=>void;endpoint?:string;payload?:unknown;onChanged?:()=>void;onCopy?:(id:number)=>void}){
|
||||
const uiText=useUiText("admin/jb_detail.php");
|
||||
|
||||
const [data,setData]=useState<{id:number;stationNumbers?:boolean;canCopy?:boolean;canAssign?:boolean;bourse?:boolean;bid?:number|null;status:number;cancelled:number;ordered:string;finished:string;price:number|null;pricePending?:boolean;paymentAllocationPending?:boolean;priceLabel?:string;scopeNotice?:string;vehicle:string;invoiceText:string;stops:{sequence:number;company:string;additional:string;street:string;houseNumber:string;zip:string;city:string;country:string;person:string;phone:string;remark:string;commission:string;completed:boolean;finished:string;recipient:string}[]}|null>(null),[error,setError]=useState(''),[cancellation,setCancellation]=useState(false),[texts,setTexts]=useState(false),[edit,setEdit]=useState(false),[assignment,setAssignment]=useState(false),[refresh,setRefresh]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setError('');setData(null);request<typeof data>(endpoint,{method:'POST',body:JSON.stringify(payload??{job}),signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[job,endpoint,payload,refresh]);
|
||||
if(edit)return <CustomerOrderEntry editJob={job} onClose={()=>setEdit(false)} onCreated={()=>{setRefresh(n=>n+1);onChanged?.();}}/>;
|
||||
if(texts)return <CustomerOrderTexts job={job} onClose={()=>setTexts(false)} onSaved={()=>{setRefresh(n=>n+1);onChanged?.();}}/>;
|
||||
if(assignment)return <CustomerCourierAssignment job={job} onClose={()=>setAssignment(false)} onSaved={()=>{setRefresh(n=>n+1);onChanged?.();}}/>;
|
||||
if(cancellation)return <CustomerCancellation job={job} onClose={()=>setCancellation(false)} onSaved={()=>{setRefresh(n=>n+1);onChanged?.();}}/>;
|
||||
return <Modal title={`Kundenauftrag #${job}`} wide onClose={onClose}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{!data&&!error&&<p>Auftrag wird geladen …</p>}{data&&<><p>{date(data.ordered)} · {data.priceLabel||"Preis"} {data.pricePending?"in Prüfung":data.paymentAllocationPending?"Zahlungszuordnung offen":data.price==null?"–":money(data.price)} · Kurier {data.vehicle||'–'}</p>{data.bourse&&<p>Aktuell günstigstes Gebot: {data.bid==null?"Noch kein Gebot":money(data.bid)}</p>}{data.scopeNotice&&<p className="alert info">{data.scopeNotice}</p>}{data.cancelled!==0&&<p className="alert info">Stornokennzeichen: {data.cancelled}</p>}{data.finished&&<p>Erledigt: {date(data.finished)}</p>}{data.stops.map((s,i)=><section key={i} style={{marginBottom:20}}><h3>{(data.stationNumbers??endpoint!=="/customer-portal/detail")&&`${s.sequence}. `}{s.company}</h3><p>{s.additional}</p><p>{s.street} {s.houseNumber}, {s.zip} {s.city} {s.country}</p><p>{s.person} {s.phone}</p>{s.commission&&<p>Kommissionsnummer: {s.commission}</p>}<p style={{whiteSpace:'pre-wrap'}}>{s.remark}</p>{s.completed&&<p>Erledigt: {date(s.finished)}{s.recipient&&` · Empfänger: ${s.recipient}`}</p>}</section>)}{data.invoiceText&&<section><h3>Rechnungstext</h3><p style={{whiteSpace:'pre-wrap'}}>{data.invoiceText}</p></section>}{endpoint==="/customer-portal/detail"&&<div key={`${job}:${refresh}`}><CustomerOrderLabels job={job}/><CustomerOrderEvidence job={job}/><CustomerOrderExtras job={job}/><CustomerOrderHistory job={job}/><CustomerOrderRoute job={job}/></div>}</>}</div><div className="modal-footer">{endpoint==="/customer-portal/detail"&&data&&[8,9].includes(data.status)&&data.cancelled===0&&<><button className="button primary" onClick={()=>setEdit(true)}>Auftrag bearbeiten</button><button className="button" onClick={()=>setTexts(true)}>Nur Texte und Kontakte bearbeiten</button></>}{endpoint==="/customer-portal/detail"&&data?.canAssign&&<button className="button" onClick={()=>setAssignment(true)}>Bevorzugten Kurier zuweisen</button>}{onCopy&&data?.canCopy&&<button className="button" onClick={()=>onCopy(job)}>Als neuen Auftrag kopieren</button>}<button className="button" disabled={!data&&!error} onClick={()=>setRefresh(n=>n+1)}>{uiText("Aktualisieren")}</button>{endpoint==="/customer-portal/detail"&&data&&[8,9].includes(data.status)&&data.cancelled===0&&<button className="button" onClick={()=>setCancellation(true)}>Stornierung prüfen</button>}<button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {CustomerEntryTours} from './CustomerEntryTours';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,ApiError} from './api';
|
||||
type Stop={editId?:number;trackingEmail?:string;costCenterId:number|null;type:'NONE'|'PICKUP'|'DELIVERY'|'BOTH';name:string;street:string;houseNumber:string;postalCode:string;city:string;country:string;contact:string;phone:string;note:string;reference:string};
|
||||
type Options={acceptanceEnabled?:boolean;tracking:boolean;cashAvailable:boolean;cashPayerSelect:boolean;splitAvailable:boolean;canRead:boolean;revision:string;base:number;payer:number;payerLabel:string;orderer:string;centers:{id:number;label:string;external:boolean}[];vehicles:{id:number;label:string;type:number}[];filters:{code:string;label:string}[];payerSelectable:boolean;remarks:boolean;countryLocked:boolean;dimensions:boolean;dimensionsRequired:boolean;defaultVehicleType:number;showPrice:boolean;referenceRequired:boolean;vehicleTypes:{id:number;label:string}[]};
|
||||
type Draft={acceptUntilTime?:string|null;trackingEmails?:string[]|null;revision:string;mode:'IMMEDIATE'|'RESERVATION';pickupAt:string|null;payerCostCenterId:number;vehicleTypeId:number;courierVehicleId:number|null;stops:Stop[];reference:string;notes:string;weight:number;length:number;width:number;height:number;positions:number;filters:string[];requestId:string;quoteFingerprint:string|null;sourceOrderId:number|null;sourceRevision:string|null;payment:'INVOICE'|'CASH';cashStop:number|null;costSplit:boolean};
|
||||
type Loaded=Pick<Draft,'acceptUntilTime'|'trackingEmails'|'payerCostCenterId'|'vehicleTypeId'|'courierVehicleId'|'stops'|'reference'|'notes'|'weight'|'length'|'width'|'height'|'positions'|'filters'|'sourceOrderId'|'sourceRevision'|'payment'|'cashStop'|'costSplit'>&{sourceName:string;warnings:string[]};
|
||||
type Preview={pricePending?:boolean;acceptUntil?:string|null;photoRequirements?:{station:number;minimum:number;maximum:number}[];trackingEmails?:string[]|null;payment:'INVOICE'|'CASH';cashStop:number|null;costSplit:boolean;allocations:{costCenterId:number;name:string;net:number|null;tax:number|null;gross:number|null}[];fingerprint:string;showPrice:boolean;net:number|null;tax:number|null;gross:number|null;batch:boolean;stations:number;message:string};
|
||||
type EditCommand={revision:string;draft:Draft;stationIds:number[];fingerprint:string|null;confirmed:boolean};
|
||||
type EditOptions={id:number;canEdit:boolean;reason:string;revision:string;options:Options;draft:Draft;stationIds:number[];ordered:string};
|
||||
type Created={id:number;number:string;legacyStatus:number;replayed:boolean;photoSyncPending:boolean};
|
||||
const empty=(type:Stop['type']):Stop=>({costCenterId:null,type,name:'',street:'',houseNumber:'',postalCode:'',city:'',country:'DE',contact:'',phone:'',note:'',reference:''});
|
||||
const money=(n:number|null)=>(n??0).toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
export function CustomerOrderEntry({onClose,onCreated,sourceOrderId,editJob}:{onClose:()=>void;onCreated:(id:number)=>void;sourceOrderId?:number|null;editJob?:number}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<Options|null>(null),[draft,setDraft]=useState<Draft|null>(null),[preview,setPreview]=useState<Preview|null>(null),[created,setCreated]=useState<Created|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(true),[dirty,setDirty]=useState(false),[ack,setAck]=useState(false),[uncertain,setUncertain]=useState(false);
|
||||
const [stale,setStale]=useState(false),[reload,setReload]=useState(0);
|
||||
const [editRevision,setEditRevision]=useState(''),[originalTime,setOriginalTime]=useState('');
|
||||
const [loaded,setLoaded]=useState<Loaded|null>(null),[toursOpen,setToursOpen]=useState(false);
|
||||
const [discard,dialog]=useDiscardConfirmation(),sending=useRef(false),pending=useRef<Draft|EditCommand|null>(null);
|
||||
const locked=busy||uncertain||!!created||stale;
|
||||
function fromSource(d:Draft,source:Loaded):Draft {const {sourceName:_name,warnings:_warnings,...values}=source;return {...d,...values,acceptUntilTime:values.acceptUntilTime??null,stops:values.stops.map((stop,i)=>({...stop,trackingEmail:values.trackingEmails?.[i]??''})),mode:'IMMEDIATE',pickupAt:null,requestId:crypto.randomUUID(),quoteFingerprint:null};}
|
||||
useEffect(()=>{const c=new AbortController();(async()=>{if(editJob){const e=await request<EditOptions>(`/customer-portal/orders/${editJob}/edit`,{signal:c.signal});if(c.signal.aborted)return;if(!e.canEdit)throw new Error(e.reason);setOptions(e.options);setEditRevision(e.revision);setOriginalTime(e.ordered);setDraft({...e.draft,quoteFingerprint:null,sourceOrderId:null,sourceRevision:null,stops:e.draft.stops.map((stop,i)=>({...stop,trackingEmail:e.draft.trackingEmails?.[i]??'',editId:e.stationIds[i]}))});return;}await request<Options>('/customer-portal/entry',{signal:c.signal}).then(async o=>{const addresses=sourceOrderId?[]:await request<Partial<Stop>[]>(`/customer-portal/entry/addresses/${o.base}`,{signal:c.signal});const source=sourceOrderId?await request<Loaded>(`/customer-portal/entry/tours/${sourceOrderId}`,{signal:c.signal}):null;if(c.signal.aborted)return;setOptions(o);const blank:Draft={revision:o.revision,mode:'IMMEDIATE',pickupAt:null,payerCostCenterId:o.payer,vehicleTypeId:o.defaultVehicleType,courierVehicleId:null,stops:[{...empty('PICKUP'),...(addresses.length===1?addresses[0]:{})},empty('DELIVERY')],reference:'',notes:'',weight:0,length:0,width:0,height:0,positions:0,filters:[],requestId:crypto.randomUUID(),quoteFingerprint:null,sourceOrderId:null,sourceRevision:null,payment:'INVOICE',cashStop:null,costSplit:false};setDraft(source?fromSource(blank,source):blank);setLoaded(source);setDirty(!!source);});})().catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[sourceOrderId,editJob,reload]);
|
||||
useEffect(()=>{if(!dirty&&!uncertain)return;const prevent=(e:BeforeUnloadEvent)=>{e.preventDefault();e.returnValue='';};window.addEventListener('beforeunload',prevent);return()=>window.removeEventListener('beforeunload',prevent);},[dirty,uncertain]);
|
||||
async function loadCurrent(){if(busy||uncertain||dirty&&!await discard('Die bisherigen Änderungen verwerfen und den aktuellen Auftrag laden?'))return;setBusy(true);setStale(false);setDirty(false);setPreview(null);setDraft(null);setAck(false);pending.current=null;setError('');setReload(n=>n+1);}
|
||||
async function loadTour(id:number){if(!draft||locked)return;if(dirty&&!await discard('Die bisherigen Eingaben durch die gespeicherte Tour ersetzen?'))return;setBusy(true);setError('');try{const source=await request<Loaded>(`/customer-portal/entry/tours/${id}`);setDraft(fromSource(draft,source));setLoaded(source);setDirty(true);setPreview(null);setAck(false);pending.current=null;setToursOpen(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
|
||||
function change(patch:Partial<Draft>){if(locked)return;setDraft(d=>d?{...d,...patch,quoteFingerprint:null}:d);setDirty(true);setPreview(null);setAck(false);setError('');pending.current=null;}
|
||||
function station(index:number,patch:Partial<Stop>){if(draft)change({stops:draft.stops.map((s,i)=>i===index?{...s,...patch}:s)});}
|
||||
async function address(index:number,id:number){if(!draft||locked)return;if(!id){station(index,{costCenterId:null});return;}setBusy(true);setError('');try{const rows=await request<Partial<Stop>[]>(`/customer-portal/entry/addresses/${id}`);if(rows.length!==1)throw new Error('Für diese Kostenstelle fehlt eine eindeutige Abholadresse.');setDraft(d=>d?{...d,stops:d.stops.map((s,i)=>i===index?{...s,...rows[0]}:s),quoteFingerprint:null}:d);setDirty(true);setPreview(null);setAck(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function check(e:FormEvent){e.preventDefault();if(!draft||locked||sending.current)return;setBusy(true);setPreview(null);setAck(false);setError('');try{const input:Draft={...draft,trackingEmails:options?.tracking?draft.stops.map(stop=>stop.trackingEmail??''):undefined,stops:draft.stops.map(({editId:_id,trackingEmail:_email,...stop})=>stop)};const command:EditCommand={revision:editRevision,draft:input,stationIds:draft.stops.map(stop=>stop.editId??0),fingerprint:null,confirmed:false};const p=await post<Preview>(editJob?`/customer-portal/orders/${editJob}/edit/preview`:'/customer-portal/entry/quote',editJob?command:input);setPreview(p);pending.current=editJob?{...command,fingerprint:p.fingerprint,confirmed:true}:{...input,quoteFingerprint:p.fingerprint};}catch(e){setError((e as Error).message);if(editJob&&e instanceof ApiError&&e.status===409)setStale(true);}finally{setBusy(false);}}
|
||||
async function save(){if(sending.current||!pending.current||!ack||created)return;sending.current=true;setBusy(true);setError('');try{const result=await post<Created>(editJob?`/customer-portal/orders/${editJob}/edit`:'/customer-portal/entry',pending.current);setCreated(result);setDirty(false);setUncertain(false);onCreated(result.id);}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status===408||e.status>=500;setUncertain(unknown);if(!unknown){setPreview(null);setAck(false);pending.current=null;if(editJob&&e instanceof ApiError&&e.status===409)setStale(true);}}finally{sending.current=false;setBusy(false);}}
|
||||
async function close(){if(busy||uncertain)return;if(!dirty||await discard(editJob?'Ungespeicherte Auftragsänderungen verwerfen?':'Ungespeicherten Kundenauftrag verwerfen?'))onClose();}
|
||||
return <><Modal title={editJob?`Kundenauftrag #${editJob} bearbeiten`:"Kundenauftrag erfassen"} onClose={()=>void close()} wide><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{busy&&!draft&&<p>Kundenvorgaben werden geladen …</p>}{options&&draft&&<>{editJob&&<p className="alert info">Sie bearbeiten Auftrag #{editJob}. Die bisherige Auftragszeit ist {new Date(originalTime).toLocaleString('de-DE')}.</p>}{!editJob&&<><button className="button" disabled={locked} aria-expanded={toursOpen} onClick={()=>setToursOpen(v=>!v)}>Gespeicherte Tour / Auftrag laden</button>{toursOpen&&<CustomerEntryTours payer={draft.payerCostCenterId} canRead={options.canRead} disabled={locked} onLoad={id=>void loadTour(id)}/>} {loaded&&<section className="alert info" style={{display:'block'}}><strong>Übernommen aus Auftrag #{loaded.sourceOrderId}{loaded.sourceName&&` · ${loaded.sourceName}`}</strong>{loaded.warnings.map(w=><p key={w}>{w}</p>)}</section>}</>}<form className="customer-entry-form" onSubmit={e=>void check(e)}><fieldset disabled={locked}><p>Auftraggeber: <strong>{options.orderer}</strong></p><div className="customer-entry-grid"><Field label="Auftragsart"><select value={draft.mode} onChange={e=>change({mode:e.target.value as Draft['mode'],pickupAt:e.target.value==='IMMEDIATE'?null:''})}><option value="IMMEDIATE">{editJob?"Bisherige Auftragszeit beibehalten":"Sofortauftrag"}</option><option value="RESERVATION">{editJob?"Abholzeit festlegen":"Vorbestellung"}</option></select></Field>{draft.mode==='RESERVATION'&&<Field label="Abholung am *"><input type="datetime-local" required value={draft.pickupAt??''} onChange={e=>change({pickupAt:e.target.value})}/></Field>}{options.acceptanceEnabled&&<Field label="Annahme bis (heute)"><input type="time" value={draft.acceptUntilTime??''} onChange={e=>change({acceptUntilTime:e.target.value||null})}/><small>Die Uhrzeit gilt für heute, auch bei Vorbestellungen. Leer lassen für die normale Disposition.</small></Field>}{(options.cashAvailable||options.splitAvailable)&&<Field label="Zahlungsart"><select value={draft.costSplit?'SPLIT':draft.payment} onChange={e=>change({payment:e.target.value==='CASH'?'CASH':'INVOICE',costSplit:e.target.value==='SPLIT',cashStop:null,payerCostCenterId:e.target.value!=='CASH'&&options.centers.find(c=>c.id===draft.payerCostCenterId)?.external?options.payer:draft.payerCostCenterId})}><option value="INVOICE">{uiText("Rechnung")}</option>{options.cashAvailable&&<option value="CASH">Barzahlung</option>}{options.splitAvailable&&<option value="SPLIT">Kostenteilung</option>}</select></Field>}{draft.payment==='CASH'&&<Field label="Kassierstation *"><select required value={draft.cashStop??''} onChange={e=>change({cashStop:e.target.value?Number(e.target.value):null})}><option value="">Bitte auswählen</option>{draft.stops.map((s,i)=><option key={i} value={i+1}>Station {i+1}: {s.name||'Freie Adresse'}</option>)}</select></Field>}<Field label={draft.payment==='CASH'?'Bezahler':'Rechnungszahler'}><select value={draft.payerCostCenterId} disabled={!options.payerSelectable} onChange={e=>change({payerCostCenterId:Number(e.target.value)})}>{!options.centers.some(c=>c.id===options.payer)&&<option value={options.payer}>{options.payerLabel}</option>}{options.centers.filter(c=>draft.payment==='CASH'||!c.external).map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label="Fahrzeugtyp"><select value={draft.vehicleTypeId} onChange={e=>change({vehicleTypeId:Number(e.target.value),courierVehicleId:null})}>{options.vehicleTypes.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>{options.vehicles.length>0&&<Field label="Gewünschter Kurier"><select value={draft.courierVehicleId??''} onChange={e=>change({courierVehicleId:e.target.value?Number(e.target.value):null,vehicleTypeId:e.target.value?options.vehicles.find(v=>v.id===Number(e.target.value))!.type:draft.vehicleTypeId})}><option value="">Nach Verfügbarkeit</option>{options.vehicles.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>}<Field label={'Kommissionsnummer'+(options.referenceRequired?' *':'')}><input maxLength={50} required={options.referenceRequired} value={draft.reference} onChange={e=>change({reference:e.target.value})}/></Field></div>
|
||||
{options.filters.length>0&&<fieldset><legend>Kurierfilter</legend>{options.filters.map(f=><label className="admin-check" key={f.code}><input type="checkbox" checked={draft.filters.includes(f.code)} onChange={e=>change({filters:e.target.checked?[...draft.filters,f.code]:draft.filters.filter(v=>v!==f.code)})}/>{f.label||f.code}</label>)}</fieldset>}
|
||||
{options.dimensions&&<div className="customer-entry-grid">{([['weight','Maximalgewicht (kg)',999999],['length','Länge (cm)',9999],['width','Breite (cm)',9999],['height','Höhe (cm)',9999],['positions','Palettenstellplätze',9999]] as const).map(([key,label,max])=><Field key={key} label={label+(options.dimensionsRequired&&key!=='positions'?' *':'')}><input type="number" required={options.dimensionsRequired&&key!=='positions'} min={options.dimensionsRequired&&key!=='positions'?1:0} max={max} step={1} value={draft[key]} onChange={e=>change({[key]:Number(e.target.value)})}/></Field>)}</div>}
|
||||
{draft.stops.map((s,index)=><section key={index} className="panel" style={{padding:16,marginTop:16}}><div className="admin-toolbar"><h3>Station {index+1}</h3><button type="button" className="button" disabled={index===0} onClick={()=>{const stops=[...draft.stops];[stops[index-1],stops[index]]=[stops[index],stops[index-1]];change({stops,cashStop:draft.cashStop===index+1?index:draft.cashStop===index?index+1:draft.cashStop});}}>Nach oben</button><button type="button" className="button" disabled={index===draft.stops.length-1} onClick={()=>{const stops=[...draft.stops];[stops[index+1],stops[index]]=[stops[index],stops[index+1]];change({stops,cashStop:draft.cashStop===index+1?index+2:draft.cashStop===index+2?index+1:draft.cashStop});}}>Nach unten</button><button type="button" className="button" disabled={draft.stops.length===1} onClick={()=>change({stops:draft.stops.filter((_,i)=>i!==index),cashStop:draft.cashStop===index+1?null:draft.cashStop!=null&&draft.cashStop>index+1?draft.cashStop-1:draft.cashStop})}>Station entfernen</button></div><div className="customer-entry-grid"><Field label={`Station ${index+1}: Kostenstellenadresse`}><select value={s.costCenterId??''} onChange={e=>void address(index,Number(e.target.value))}><option value="">Freie Adresse</option>{options.centers.map(c=><option key={c.id} value={c.id}>{c.label}</option>)}</select></Field><Field label={`Station ${index+1}: Warenbewegung`}><select value={s.type} onChange={e=>station(index,{type:e.target.value as Stop['type']})}><option value="NONE">Keine Warenbewegung</option><option value="PICKUP">{uiText("Abholung")}</option><option value="DELIVERY">Zustellung</option><option value="BOTH">Abholung und Zustellung</option></select></Field>{([['name','Firma / Name',50,true],['street','Straße',50,true],['houseNumber','Hausnummer',10,true],['postalCode','PLZ',10,true],['city','Ort',50,true],['country','Land',2,true],['contact','Ansprechpartner',50,false],['phone','Telefon',20,false],['reference','Referenz',30,false]] as const).map(([key,label,max,required])=><Field key={key} label={`Station ${index+1}: ${label}${required?' *':''}`}><input value={s[key]} maxLength={max} required={required} readOnly={key==='country'&&options.countryLocked} onChange={e=>station(index,{[key]:key==='country'?e.target.value.toUpperCase():e.target.value})}/></Field>)}{options.tracking&&<Field label={`Station ${index+1}: Tracking-E-Mail`}><input type="email" maxLength={60} value={s.trackingEmail??''} onChange={e=>station(index,{trackingEmail:e.target.value})}/></Field>}{options.remarks&&<Field label={`Station ${index+1}: Bemerkung`}><textarea maxLength={2000} value={s.note} onChange={e=>station(index,{note:e.target.value})}/></Field>}</div></section>)}<p><button className="button" type="button" disabled={draft.stops.length>=100} onClick={()=>change({stops:[...draft.stops,empty('DELIVERY')]})}>Station hinzufügen</button></p><Field label="Auftrags- / Rechnungstext"><textarea maxLength={4000} value={draft.notes} onChange={e=>change({notes:e.target.value})}/></Field><button className="button" type="submit">{options.showPrice?'Auftrag und Preis prüfen':'Auftrag prüfen'}</button></fieldset></form></>}
|
||||
{preview&&!created&&<section className="customer-settings-preview"><h3>Auftrag prüfen</h3><p>{preview.payment==='CASH'?`Barzahlung an Station ${preview.cashStop}`:preview.costSplit?'Kostenteilung':'Rechnungszahlung'}</p><p className={preview.pricePending?"alert info":undefined} role={preview.pricePending?"status":undefined}>{preview.stations} Stationen · {preview.message}</p>{preview.acceptUntil&&<p>Annahme bis: <strong>{new Date(preview.acceptUntil).toLocaleString('de-DE')}</strong></p>}{preview.trackingEmails?.some(Boolean)&&<div><strong>{uiText("Sendungsverfolgung")}</strong>{preview.trackingEmails.map((email,index)=>email&&<p key={index}>Station {index+1}: {email}</p>)}</div>}{!!preview.photoRequirements?.length&&<div><strong>Beweisfotos laut Kundenvorgabe</strong>{preview.photoRequirements.map(p=><p key={p.station}>Station {p.station}: {p.minimum}–{p.maximum} Fotos</p>)}</div>}{preview.showPrice&&<p>Netto <strong>{money(preview.net)}</strong> · Steuer {money(preview.tax)} · Gesamt <strong>{money(preview.gross)}</strong></p>}{preview.allocations?.length>0&&<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Kostenstelle")}</th>{preview.showPrice&&<><th>Netto</th><th>Steuer</th><th>Gesamt</th></>}</tr></thead><tbody>{preview.allocations.map(a=><tr key={a.costCenterId}><td>{a.name}</td>{preview.showPrice&&<><td>{money(a.net)}</td><td>{money(a.tax)}</td><td>{money(a.gross)}</td></>}</tr>)}</tbody></table></div>}<label className="admin-check"><input type="checkbox" disabled={busy||uncertain} checked={ack} onChange={e=>setAck(e.target.checked)}/>Auftragsdaten geprüft</label></section>}{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte das Ergebnis erneut abrufen; dieselbe Anfrage wird nur einmal gespeichert.</p>}{created&&<p className="alert success" role="status">Auftrag #{created.number} wurde {created.replayed?'bereits ':''}{editJob?'geändert':'gespeichert'}.{preview?.pricePending?' Der Fahrpreis wird von der Zentrale geprüft.':''}{created.photoSyncPending?' Die Fotoübertragung steht noch aus.':''}</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{editJob&&!created&&<button className="button" disabled={busy||uncertain} onClick={()=>void loadCurrent()}>Auftrag neu laden</button>}{preview&&!created&&<button className="button primary" disabled={busy||!ack} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':editJob?uiText("Änderungen speichern"):'Auftrag senden'}</button>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {request,downloadBusinessFile} from './api';
|
||||
import {Modal} from './FormElements';
|
||||
type Field={label:string;value:string};
|
||||
type ArticleTable={columns:{label:string;type:string}[];rows:{id:number;cells:string[]}[]};
|
||||
type Picture={key:string;name:string};
|
||||
type Evidence={pricePending?:boolean;summary:Field[];services:{id:number;center:string;name:string;price:number|null}[];serviceTotal:number|null;toll?:number|null;totalWithToll?:number|null;serviceBasis:string;partial:boolean;stations:{label?:string;articles?:ArticleTable;id:number;sequence:number;fields:Field[];signature:string;photos:Picture[];warnings:string[]}[];warnings:string[];canPdf:boolean;revision:string};
|
||||
type Image={name:string;mime:string;data:string};
|
||||
const money=(n:number|null|undefined)=>n==null?"–":n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
function Fields({values}:{values:Field[]}){return <dl className="customer-order-fields">{values.map((f,i)=><div key={i}><dt>{f.label}</dt><dd style={{whiteSpace:'pre-wrap'}}>{f.value}</dd></div>)}</dl>;}
|
||||
export function CustomerOrderEvidence({job}:{job:number}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/customer-portal/orders/${job}`,[data,setData]=useState<Evidence|null>(null),[error,setError]=useState(''),[image,setImage]=useState<Image|null>(null),[imageError,setImageError]=useState(''),[busy,setBusy]=useState(false),[pdf,setPdf]=useState(false);const loadingImage=useRef<AbortController|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');request<Evidence>(base+'/evidence',{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>{c.abort();loadingImage.current?.abort();};},[base]);
|
||||
async function photo(station:number,p:Picture){if(busy)return;const c=new AbortController();loadingImage.current=c;setBusy(true);setImage(null);setImageError('');try{const i=await request<Image>(`${base}/stations/${station}/photos/${p.key}`,{signal:c.signal});if(!c.signal.aborted)setImage(i);}catch(e){if(!c.signal.aborted)setImageError((e as Error).message);}finally{if(!c.signal.aborted)setBusy(false);}}
|
||||
return <section className="customer-order-evidence"><h3>Auftragsdaten und Abliefernachweise</h3>{error&&<p className="alert error" role="alert">{error}</p>}{!data&&!error&&<p>Nachweise werden geladen …</p>}{data&&<><Fields values={data.summary}/>{data.warnings.map((w,i)=><p className="alert info" key={i}>{w}</p>)}<h4>Leistungen · {data.serviceBasis}</h4>{data.services.length?<><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Kostenstelle")}</th><th>{uiText("Leistung")}</th><th>{uiText("Betrag")}</th></tr></thead><tbody>{data.services.map(s=><tr key={s.id}><td>{s.center}</td><td>{s.name}</td><td>{data.pricePending?'Preis in Prüfung':money(s.price)}</td></tr>)}</tbody></table></div><p><strong>Leistungssumme: {data.pricePending?'Preis in Prüfung':money(data.serviceTotal)}</strong>{data.partial?' · freigegebene Kostenstellen':''}</p></>:<p>Keine Leistungen für Ihre freigegebenen Kostenstellen vorhanden.</p>}{data.toll!=null&&<p>Maut: {money(data.toll)} · <strong>Gesamt einschließlich Maut: {data.pricePending?'Preis in Prüfung':money(data.totalWithToll??data.serviceTotal)}</strong>{data.partial?' · freigegebene Kostenstellen':''}</p>}{data.stations.map(s=><section key={s.id} style={{marginTop:20}}><h4>Nachweise · {s.label??`Station ${s.sequence}`}</h4><Fields values={s.fields}/>{s.articles&&s.articles.rows.length>0&&<div className="admin-table-wrap"><table className="admin-table"><caption>Artikel · {s.label??`Station ${s.sequence}`}</caption><thead><tr>{s.articles.columns.map((c,i)=><th key={i}>{c.label}</th>)}</tr></thead><tbody>{s.articles.rows.map(r=><tr key={r.id}>{r.cells.map((v,i)=><td key={i}>{s.articles!.columns[i].type==='money'&&v!==''?money(Number(v)):v}</td>)}</tr>)}</tbody></table></div>}{s.warnings.map((w,i)=><p key={i} className="alert info">{w}</p>)}{s.signature&&<figure style={{margin:'12px 0'}}><figcaption>Unterschrift · {s.label??`Station ${s.sequence}`}</figcaption><img alt={`Unterschrift · ${s.label??`Station ${s.sequence}`}`} src={s.signature} style={{maxWidth:'100%',maxHeight:130,objectFit:'contain'}}/></figure>}<div className="admin-toolbar">{s.photos.map(p=><button className="button" key={p.key} disabled={busy} onClick={()=>void photo(s.id,p)}>{s.label??`Station ${s.sequence}`}: {p.name}</button>)}</div>{!s.signature&&!s.fields.length&&!s.photos.length&&!s.warnings.length&&<p>Keine zusätzlichen Nachweise gespeichert.</p>}</section>)}{busy&&<p>Stationsfoto wird geladen …</p>}{imageError&&<p className="alert error" role="alert">{imageError}</p>}{image&&<figure style={{margin:0}}><img alt={image.name} src={image.data} style={{maxWidth:'100%',maxHeight:420,objectFit:'contain'}}/><figcaption><a className="button" href={image.data} download={image.name}>Stationsfoto herunterladen</a> <button className="button" onClick={()=>setImage(null)}>Stationsfoto schließen</button></figcaption></figure>}{data.canPdf&&<div className="admin-toolbar" style={{marginTop:20}}><button className="button" onClick={()=>setPdf(true)}>Auftragsbeleg als PDF</button></div>}{pdf&&<CustomerOrderPdf job={job} revision={data.revision} onClose={()=>setPdf(false)}/>}</>}</section>;
|
||||
}
|
||||
function CustomerOrderPdf({job,revision,onClose}:{job:number;revision:string;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [page,setPage]=useState(0),[preview,setPreview]=useState<{page:number;pages:number;png:string}|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false);const base=`/customer-portal/orders/${job}`;
|
||||
useEffect(()=>{const c=new AbortController();setPreview(null);setError('');setLoading(true);request<{page:number;pages:number;png:string}>(`${base}/pdf-page?`+new URLSearchParams({revision,page:String(page)}),{signal:c.signal}).then(p=>{if(!c.signal.aborted)setPreview(p);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[base,revision,page]);
|
||||
async function download(){if(busy||!preview)return;setBusy(true);setError('');try{await downloadBusinessFile(`${base}/pdf?`+new URLSearchParams({revision}),undefined,`Auftrag-${job}.pdf`,'GET');}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
return <Modal title={`Auftragsbeleg #${job}`} wide onClose={()=>{if(!busy)onClose();}}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{loading&&<p>PDF-Vorschau wird erstellt …</p>}{preview&&<><p>Seite {preview.page+1} / {preview.pages}</p><img alt={`Auftragsbeleg Seite ${preview.page+1}`} src={'data:image/png;base64,'+preview.png} style={{display:'block',width:'100%',height:'auto'}}/></>}</div><div className="modal-footer"><button className="button" disabled={busy} onClick={onClose}>{uiText("Schließen")}</button><button className="button" disabled={loading||busy||page===0||!preview} onClick={()=>setPage(p=>p-1)}>Vorherige Seite</button><button className="button" disabled={loading||busy||!preview||page+1>=preview.pages} onClick={()=>setPage(p=>p+1)}>Nächste Seite</button><button className="button primary" disabled={!preview||busy||loading} onClick={()=>void download()}>PDF herunterladen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {request} from './api';
|
||||
type Items={enabled:boolean;message?:string;columns?:{label:string;type:string}[];articles?:{id:number;cells:string[]}[];items?:{name:string;quantity:number;unitPrice:number;price:number}[];pieces?:number;weight?:number;goodsValue?:number;total?:number;tax?:number;additional?:string};
|
||||
type Photo={key:string;name:string;source:string;canOpen:boolean;status:string};
|
||||
type Gallery={enabled:boolean;photos:Photo[];warnings:string[]};
|
||||
type Image={name:string;mime:string;data:string};
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'}),decimal=(n:number)=>n.toLocaleString('de-DE',{maximumFractionDigits:3});
|
||||
export function CustomerOrderExtras({job}:{job:number}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/customer-portal/orders/${job}`;
|
||||
const [items,setItems]=useState<Items|null>(null),[photos,setPhotos]=useState<Gallery|null>(null),[errors,setErrors]=useState<string[]>([]),[image,setImage]=useState<Image|null>(null),[imageError,setImageError]=useState(''),[busy,setBusy]=useState(false);
|
||||
const pictureRequest=useRef<AbortController|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setItems(null);setPhotos(null);setErrors([]);Promise.allSettled([request<Items>(base+'/items',{signal:c.signal}),request<Gallery>(base+'/photos',{signal:c.signal})]).then(([i,p])=>{if(c.signal.aborted)return;const messages:string[]=[];if(i.status==='fulfilled')setItems(i.value);else messages.push('Artikel / Rechnungsposten: '+i.reason.message);if(p.status==='fulfilled')setPhotos(p.value);else messages.push('Fotos: '+p.reason.message);setErrors(messages);});return()=>{c.abort();pictureRequest.current?.abort();};},[base]);
|
||||
async function showPhoto(photo:Photo){if(busy||!photo.canOpen)return;const c=new AbortController();pictureRequest.current=c;setBusy(true);setImage(null);setImageError('');try{const img=await request<Image>(base+'/photos/'+photo.key,{signal:c.signal});if(!c.signal.aborted)setImage(img);}catch(e){if(!c.signal.aborted)setImageError((e as Error).message);}finally{if(!c.signal.aborted)setBusy(false);}}
|
||||
return <>{errors.map(e=><p className="alert error" key={e} role="alert">{e}</p>)}
|
||||
{items?.enabled&&<section><h3>Artikel und Rechnungsposten</h3>{items.message?<p>{items.message}</p>:<><h4>Artikel an Station 2</h4>{items.articles?.length?<><div className="admin-table-wrap"><table className="admin-table"><thead><tr>{items.columns?.map((c,i)=><th key={i}>{c.label}</th>)}</tr></thead><tbody>{items.articles.map(a=><tr key={a.id}>{a.cells.map((v,i)=><td key={i}>{items.columns?.[i].type==='money'?money(Number(v)):items.columns?.[i].type==='decimal'?decimal(Number(v)):v}</td>)}</tr>)}</tbody></table></div><p>{decimal(items.pieces??0)} Packstücke · Gesamtgewicht {decimal(items.weight??0)} kg · Warenwert {money(items.goodsValue??0)}</p>{items.additional&&<p style={{whiteSpace:'pre-wrap'}}>{items.additional}</p>}</>:<p>Keine Artikel an Station 2.</p>}
|
||||
<h4>Rechnungsposten an Station 2</h4>{items.items?.length?<><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Posten</th><th>Anzahl</th><th>{uiText("Preis")}</th><th>Gesamtpreis</th></tr></thead><tbody>{items.items.map((v,i)=><tr key={i}><td>{v.name}</td><td>{decimal(v.quantity??0)}</td><td>{money(v.unitPrice)}</td><td>{money(v.price)}</td></tr>)}</tbody></table></div><p><strong>Gesamt: {money(items.total??0)}</strong> · inklusive {decimal(items.tax??0)} % Umsatzsteuer</p></>:<p>Keine Rechnungsposten an Station 2.</p>}</>}</section>}
|
||||
{photos?.enabled&&<section><h3>Fotos der Endgeräte</h3>{photos.warnings.map(w=><p className="alert info" key={w}>{w}</p>)}<div className="admin-toolbar">{photos.photos.map(p=><button className="button" key={p.key} disabled={busy||!p.canOpen} title={`${p.source} · ${p.status}`} onClick={()=>void showPhoto(p)}>{p.name}{!p.canOpen?' · nicht verfügbar':''}</button>)}</div>{!photos.photos.length&&<p>Keine mobilen Fotos zu diesem Auftrag vorhanden.</p>}{busy&&<p>Foto wird geladen …</p>}{imageError&&<p className="alert error" role="alert">{imageError}</p>}{image&&<figure style={{margin:0}}><img src={image.data} alt={image.name} style={{display:'block',maxWidth:'100%',maxHeight:420,objectFit:'contain',borderRadius:6}}/><figcaption><a className="button" href={image.data} download={image.name}>Foto herunterladen</a> <button className="button" onClick={()=>setImage(null)}>Foto schließen</button></figcaption></figure>}</section>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
type History={mode:'creator'|'history';page:number;hasNext:boolean;rows:{id:number;action:string;at:string|null;name:string;firstName:string;phone:string;email:string}[]};
|
||||
export function CustomerOrderHistory({job}:{job:number}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<History|null>(null),[page,setPage]=useState(0),[error,setError]=useState(''),[loading,setLoading]=useState(false);
|
||||
useEffect(()=>{const c=new AbortController();setLoading(true);setError('');setData(null);request<History>(`/customer-portal/orders/${job}/history?page=${page}`,{signal:c.signal}).then(r=>{if(!c.signal.aborted)setData(r);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[job,page]);
|
||||
return <section style={{marginTop:24}}><h3>{data?.mode==='creator'?'Auftragserfasser':'Mitarbeiterhistorie'}</h3>{error&&<p className="alert error" role="alert">{error}</p>}{loading&&<p>Mitarbeiterdaten werden geladen …</p>}{data&&<><div className="admin-table-wrap"><table className="admin-table"><thead><tr>{data.mode==='history'&&<><th>{uiText("Vorgang")}</th><th>{uiText("Zeitpunkt")}</th></>}<th>{uiText("Name")}</th><th>{uiText("Vorname")}</th><th>{uiText("Telefon")}</th>{data.mode==='creator'&&<th>{uiText("E-Mail")}</th>}</tr></thead><tbody>{data.rows.map(r=><tr key={r.id}>{data.mode==='history'&&<><td>{r.action}</td><td>{r.at?new Date(r.at).toLocaleString('de-DE'):'–'}</td></>}<td>{r.name}</td><td>{r.firstName}</td><td>{r.phone}</td>{data.mode==='creator'&&<td>{r.email}</td>}</tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine freigegebenen Mitarbeiterdaten vorhanden.</p>}</>}{(page>0||data?.hasNext)&&<div className="admin-toolbar"><button className="button" disabled={loading||page===0} onClick={()=>setPage(n=>n-1)}>Vorherige Historienseite</button><span>Seite {page+1}</span><button className="button" disabled={loading||!data?.hasNext} onClick={()=>setPage(n=>n+1)}>Nächste Historienseite</button></div>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {request,downloadBusinessFile} from './api';
|
||||
type Kind='OUTBOUND'|'RETURN';
|
||||
type Option={kind:Kind;label:string;available:boolean;message:string};
|
||||
type Labels={title:string;size:string;width:number;height:number;count:number;revision:string};
|
||||
export function CustomerOrderLabels({job,central=false,onPreviewChange}:{job:number;central?:boolean;onPreviewChange?:(open:boolean)=>void}){
|
||||
const endpoint=`${central?'/orders':'/customer-portal/orders'}/${job}/labels`;
|
||||
const [options,setOptions]=useState<Option[]>([]),[error,setError]=useState(''),[kind,setKind]=useState<Kind|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setOptions([]);setError('');request<Option[]>(endpoint,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setOptions(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[endpoint]);
|
||||
useEffect(()=>{onPreviewChange?.(kind!==null);return()=>onPreviewChange?.(false);},[kind,onPreviewChange]);
|
||||
return <section style={{margin:'20px 0'}}><h3>Etiketten</h3>{error&&<p className="alert error" role="alert">{error}</p>}<div className="admin-toolbar">{options.map(o=><button key={o.kind} className="button" disabled={!o.available} title={o.message} onClick={()=>setKind(o.kind)}>{o.label}</button>)}</div>{options.filter(o=>o.message).map(o=><p key={o.kind} className="alert info">{o.label}: {o.message}</p>)}{kind&&<LabelPreview job={job} endpoint={endpoint} kind={kind} onClose={()=>setKind(null)}/>}</section>;
|
||||
}
|
||||
function LabelPreview({job,endpoint,kind,onClose}:{job:number;endpoint:string;kind:Kind;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`${endpoint}/${kind}`,[data,setData]=useState<Labels|null>(null),[page,setPage]=useState(0),[preview,setPreview]=useState<{page:number;pages:number;png:string}|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[reload,setReload]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setPreview(null);setError('');setPage(0);request<Labels>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setData(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base,reload]);
|
||||
useEffect(()=>{if(!data)return;const c=new AbortController();setPreview(null);setError('');request<{page:number;pages:number;png:string}>(base+'/page?'+new URLSearchParams({revision:data.revision,page:String(page)}),{signal:c.signal}).then(v=>{if(!c.signal.aborted)setPreview(v);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base,data,page]);
|
||||
async function download(){if(!data||busy||!preview)return;setBusy(true);setError('');try{await downloadBusinessFile(base+'/pdf?'+new URLSearchParams({revision:data.revision}),undefined,`Etiketten-${job}${kind==='RETURN'?'-Retoure':''}.pdf`,'GET');}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
return <Modal title={`${kind==='RETURN'?'Etiketten Retoure':'Etiketten'} · Auftrag #${job}`} wide onClose={()=>{if(!busy)onClose();}}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{!data&&!error&&<p>Etiketten werden vorbereitet …</p>}{data&&<><p>{data.count} {data.count===1?'Etikett':'Etiketten'} · Format {data.width} × {data.height} mm · je Zielstation ein Blatt</p><p className="form-hint">Beim Drucken 100 % / tatsächliche Größe verwenden.</p>{!preview&&!error&&<p>PDF-Vorschau wird erstellt …</p>}{preview&&<><p>Etikett {preview.page+1} / {preview.pages}</p><img alt={`Etikett ${preview.page+1}`} src={'data:image/png;base64,'+preview.png} style={{display:'block',width:'100%',maxWidth:680,height:'auto',border:'1px solid #ddd',margin:'16px auto'}}/></>}</>}</div><div className="modal-footer"><button className="button" disabled={busy} onClick={onClose}>{uiText("Schließen")}</button><button className="button" disabled={busy} onClick={()=>setReload(n=>n+1)}>Neu laden</button><button className="button" disabled={busy||!preview||page===0} onClick={()=>setPage(n=>n-1)}>Vorheriges Etikett</button><button className="button" disabled={busy||!preview||page+1>=preview.pages} onClick={()=>setPage(n=>n+1)}>Nächstes Etikett</button><button className="button primary" disabled={busy||!preview} onClick={()=>void download()}>PDF herunterladen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
type Route={enabled:boolean;available?:boolean;source?:string;locatedAt?:string;destination?:string;station?:number;url?:string;message?:string};
|
||||
export function CustomerOrderRoute({job}:{job:number}){
|
||||
const [data,setData]=useState<Route|null>(null),[error,setError]=useState(''),[refresh,setRefresh]=useState(0),[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');setBusy(true);request<Route>(`/customer-portal/orders/${job}/route`,{signal:c.signal}).then(r=>{if(!c.signal.aborted)setData(r);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[job,refresh]);
|
||||
if(!error&&!data?.enabled)return null;
|
||||
return <section style={{marginTop:24}}><h3>Aktuelle Route</h3>{error&&<p className="alert error" role="alert">{error}</p>}{data?.message&&<p>{data.message}</p>}{data?.available&&<><p>{data.source}{data.locatedAt?` · Stand ${new Date(data.locatedAt).toLocaleString('de-DE')}`:''} → Station {data.station}: {data.destination}</p><a className="button" href={data.url} target="_blank" rel="noopener noreferrer">Route in Google Maps öffnen</a></>} <button className="button" disabled={busy} onClick={()=>setRefresh(n=>n+1)}>Route aktualisieren</button></section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,request} from './api';
|
||||
type Stop={id:number;sequence:number;address:string;company:string;person:string;phone:string;reference:string;remark:string};
|
||||
type Options={id:number;revision:string;canEdit:boolean;reason:string;remarks:boolean;referenceRequired:boolean;reference:string;notes:string;stops:Stop[]};
|
||||
type Command={requestId:string;revision:string;reference:string;notes:string;stops:Stop[];confirmed:boolean};
|
||||
type Preview={changes:{field:string;before:string;after:string}[]};
|
||||
export function CustomerOrderTexts({job,onClose,onSaved}:{job:number;onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [data,setData]=useState<Options|null>(null),[dirty,setDirty]=useState(false),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[done,setDone]=useState(false);
|
||||
const pending=useRef<Command|null>(null),sending=useRef(false),base=`/customer-portal/orders/${job}/texts`,locked=busy||uncertain||stale||done;
|
||||
const [discard,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();request<Options>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[base]);
|
||||
useEffect(()=>{if(!dirty&&!uncertain)return;const prevent=(e:BeforeUnloadEvent)=>{e.preventDefault();e.returnValue='';};window.addEventListener('beforeunload',prevent);return()=>window.removeEventListener('beforeunload',prevent);},[dirty,uncertain]);
|
||||
function change(patch:Partial<Options>){if(locked)return;setData(d=>d?{...d,...patch}:d);setDirty(true);setPreview(null);setConfirmed(false);pending.current=null;setError('');}
|
||||
async function reload(){if(busy||uncertain||dirty&&!await discard('Die bisherigen Änderungen verwerfen und den aktuellen Auftrag laden?'))return;setBusy(true);setError('');try{setData(await request<Options>(base));setDirty(false);setPreview(null);setConfirmed(false);setStale(false);pending.current=null;}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function check(){if(!data||locked)return;setBusy(true);setError('');const command={requestId:crypto.randomUUID(),revision:data.revision,reference:data.reference,notes:data.notes,stops:data.stops,confirmed:true};try{setPreview(await post<Preview>(base+'/preview',command));pending.current=command;}catch(e){setError((e as Error).message);setStale(e instanceof ApiError&&e.status===409);}finally{setBusy(false);}}
|
||||
async function save(){if(sending.current||!pending.current||!confirmed)return;sending.current=true;setBusy(true);setError('');try{await post(base,pending.current);setDone(true);setDirty(false);setUncertain(false);onSaved();}catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status===408||e.status>=500;setUncertain(unknown);if(!unknown){setPreview(null);setConfirmed(false);pending.current=null;setStale(e instanceof ApiError&&e.status===409);}}finally{sending.current=false;setBusy(false);}}
|
||||
async function close(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Änderungen am Kundenauftrag verwerfen?'))onClose();}
|
||||
return <><Modal title={`Auftrag #${job}: Texte und Kontakte`} onClose={()=>void close()} wide><div className="modal-body">{error&&<p role="alert" className="alert error">{error}</p>}{!data&&!error&&<p>Auftrag wird geladen …</p>}{done?<p role="status" className="alert success">Die Änderungen an Auftrag #{job} wurden gespeichert.</p>:data&&<>{!data.canEdit?<p className="alert info">{data.reason}</p>:<><p>Kommissionsnummern, Auftragstext, Namen und Stationskontakte korrigieren. Die Rechnungsdarstellung wird zur Aktualisierung vorgemerkt.</p><form onSubmit={e=>{e.preventDefault();void check();}}><fieldset disabled={locked}><Field label={'Kommissionsnummer'+(data.referenceRequired?' *':'')}><input value={data.reference} required={data.referenceRequired} maxLength={50} onChange={e=>change({reference:e.target.value})}/></Field><Field label="Auftragstext"><textarea value={data.notes} maxLength={10000} onChange={e=>change({notes:e.target.value})}/></Field>{data.stops.map((s,i)=><section className="panel" key={s.id} style={{padding:16,marginTop:16}}><h3>Station {s.sequence}</h3><p>{s.address}</p><div className="customer-entry-grid">{([['company','Firma / Name',50],['person','Kontakt',50],['phone','Telefon',20],['reference','Kommissionsnummer',30]] as const).map(([key,label,max])=><Field key={key} label={`Station ${s.sequence}: ${label}`}><input value={s[key]} required={key==='company'} maxLength={max} onChange={e=>change({stops:data.stops.map((v,n)=>n===i?{...v,[key]:e.target.value}:v)})}/></Field>)}{data.remarks&&<Field label={`Station ${s.sequence}: Bemerkung`}><textarea value={s.remark} maxLength={10000} onChange={e=>change({stops:data.stops.map((v,n)=>n===i?{...v,remark:e.target.value}:v)})}/></Field>}</div></section>)}<p><button className="button" disabled={!dirty}>Änderungen prüfen</button></p></fieldset></form>{preview&&<section><h3>Änderungen prüfen</h3><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Feld</th><th>Bisher</th><th>Neu</th></tr></thead><tbody>{preview.changes.map(c=><tr key={c.field}><th>{c.field}</th><td style={{whiteSpace:'pre-wrap'}}>{c.before||'–'}</td><td style={{whiteSpace:'pre-wrap'}}>{c.after||'–'}</td></tr>)}</tbody></table></div><label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy||uncertain||stale} onChange={e=>setConfirmed(e.target.checked)}/>Änderungen geprüft und bestätigt</label></section>}</>}</>}{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte dieselbe Anfrage erneut abrufen.</p>}{stale&&<p className="alert info">Der Auftrag wurde inzwischen geändert. Bitte den aktuellen Stand laden und erneut prüfen.</p>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button>}{preview&&!done&&<button className="button primary" disabled={busy||!confirmed||stale} onClick={()=>void save()}>{uncertain?'Ergebnis erneut abrufen':uiText("Änderungen speichern")}</button>}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {request,ApiError} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
type Flow={number:number;name:string;minimum:number;maximum:number};
|
||||
type Definition={number:number;name:string;workflows:Flow[]};
|
||||
type Selection={setNumber:number;allowedWorkflowNumbers:number[];defaultWorkflowNumber:number};
|
||||
type Values={defaultSetNumber:number;sets:Selection[]};
|
||||
type Operation={requestId:string;status:string;canCheck:boolean;message:string};
|
||||
type View={customer:number;label:string;configured:boolean;revision:string;definitions:Definition[];values:Values;operations:Operation[]};
|
||||
type Preview={fingerprint:string;before:Values;after:Values};
|
||||
type Command={requestId:string;draft:{revision:string;values:Values};fingerprint:string};
|
||||
export function CustomerPhotoSettings({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const base=`/master/customers/${customer}/photo-settings`;
|
||||
const [data,setData]=useState<View|null>(null),[values,setValues]=useState<Values|null>(null),[preview,setPreview]=useState<Preview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[uncertain,setUncertain]=useState(false);
|
||||
const command=useRef<Command|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&JSON.stringify(values)!==JSON.stringify(data.values);const waiting=!!data?.operations.length;
|
||||
async function load(){setBusy(true);setError('');try{const d=await request<View>(base);setData(d);setValues(d.values);setPreview(null);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
useEffect(()=>{void load();},[customer]);
|
||||
function change(next:Values){setValues(next);setPreview(null);command.current=null;setNotice('');}
|
||||
function setSelection(set:number,patch:Partial<Selection>){if(values)change({...values,sets:values.sets.map(s=>s.setNumber===set?{...s,...patch}:s)});}
|
||||
async function inspect(){if(!data||!values)return;setBusy(true);setError('');try{setPreview(await request<Preview>(base+'/preview',{method:'POST',body:JSON.stringify({revision:data.revision,values})}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!data||!values||!preview)return;command.current??={requestId:crypto.randomUUID(),draft:{revision:data.revision,values},fingerprint:preview.fingerprint};setBusy(true);setError('');try{const result=await request<Operation>(base,{method:'POST',body:JSON.stringify(command.current)});setUncertain(false);command.current=null;setNotice(result.message);await load();}catch(e){const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);if(!unknown)command.current=null;setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function check(op:Operation){setBusy(true);setError('');try{const result=await request<Operation>(`${base}/${op.requestId}/check`,{method:'POST'});setNotice(result.message);await load();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function close(){if(!busy&&!uncertain&&(!dirty||await discard('Ungespeicherte Fotoeinstellungen verwerfen?')))onClose();}
|
||||
function describe(v:Values){return <><p>Standardset: {data?.definitions.find(d=>d.number===v.defaultSetNumber)?.name??'Keine Vorgabe'}</p>{v.sets.map(s=><p key={s.setNumber}><strong>{data?.definitions.find(d=>d.number===s.setNumber)?.name}</strong>: {data?.definitions.find(d=>d.number===s.setNumber)?.workflows.filter(f=>s.allowedWorkflowNumbers.includes(f.number)).map(f=>f.name).join(', ')||'Keine Workflows freigegeben'} · Standard: {s.defaultWorkflowNumber===0?'Keine abweichende Vorgabe':data?.definitions.find(d=>d.number===s.setNumber)?.workflows.find(f=>f.number===s.defaultWorkflowNumber)?.name}</p>)}</>;}
|
||||
return <><Modal title="Foto-Workflow-Sets des Kunden" wide onClose={()=>void close()}><div className="modal-body">{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{!data&&!error&&<p>Fotoeinstellungen werden geladen …</p>}{data&&<><h3>{data.label}</h3>{!data.configured?<p className="alert info">Für diese Niederlassung ist kein Fotodienst eingerichtet.</p>:<><p>Freigegebene Workflows und Standardauswahl für künftige Auftragserfassungen. Bestehende Stationsauswahlen bleiben erhalten.</p>{data.operations.map(op=><section className="alert info" key={op.requestId}><p>{op.message}</p>{op.canCheck?<button className="button" disabled={busy} onClick={()=>void check(op)}>{op.status==='PENDING'?'Übertragung fortsetzen':'Status prüfen'}</button>:<p>Der ursprüngliche Bearbeiter kann den Übertragungsstatus prüfen.</p>}</section>)}<fieldset className="business-fieldset" disabled={busy||uncertain||waiting}>{values&&<><Field label="Standard-Foto-Set"><select value={values.defaultSetNumber} onChange={e=>change({...values,defaultSetNumber:Number(e.target.value)})}><option value="0">Keine Vorgabe</option>{data.definitions.map(d=><option key={d.number} value={d.number}>{d.name}</option>)}</select></Field>{data.definitions.map(d=>{const selection=values.sets.find(s=>s.setNumber===d.number)!;return <section className="business-section" key={d.number}><h3>{d.name}</h3><div className="form-grid">{d.workflows.map(f=><label className="checkbox-label" key={f.number}><input type="checkbox" checked={selection.allowedWorkflowNumbers.includes(f.number)} onChange={e=>{const allowed=e.target.checked?[...selection.allowedWorkflowNumbers,f.number].sort((a,b)=>a-b):selection.allowedWorkflowNumbers.filter(n=>n!==f.number);setSelection(d.number,{allowedWorkflowNumbers:allowed,...(!e.target.checked&&selection.defaultWorkflowNumber===f.number?{defaultWorkflowNumber:0}:{})});}}/>{f.name} · {f.minimum===f.maximum?f.minimum:`${f.minimum}–${f.maximum}`} Fotos</label>)}</div><Field label={`Standard-Workflow · ${d.name}`}><select value={selection.defaultWorkflowNumber} onChange={e=>setSelection(d.number,{defaultWorkflowNumber:Number(e.target.value)})}><option value="0">Keine abweichende Vorgabe</option>{selection.defaultWorkflowNumber!==0&&!selection.allowedWorkflowNumbers.includes(selection.defaultWorkflowNumber)&&<option value={selection.defaultWorkflowNumber}>Bisheriger Standard nicht freigegeben – bitte korrigieren</option>}{d.workflows.filter(f=>f.number!==0&&selection.allowedWorkflowNumbers.includes(f.number)).map(f=><option key={f.number} value={f.number}>{f.name}</option>)}</select></Field></section>;})}</>}</fieldset>{preview&&<section className="alert info" aria-label="Vorschau der Fotoeinstellungen"><h3>Zu übertragende Fotoeinstellungen</h3>{describe(preview.after)}<p>Speichern überträgt diese Auswahl an den Fotodienst.</p></section>}{uncertain&&<p className="alert info">Die Antwort fehlt. Bitte denselben Vorgang erneut prüfen; Änderungen bleiben bis zur Klärung gesperrt.</p>}</>}</>}</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Schließen</button><button className="button" disabled={busy||uncertain} onClick={async()=>{if(!dirty||await discard('Ungespeicherte Fotoeinstellungen verwerfen?'))void load();}}>Neu laden</button><button className="button" disabled={busy||uncertain||waiting||!dirty||!data?.configured} onClick={()=>void inspect()}>Änderungen prüfen</button><button className="button primary" disabled={busy||!preview||waiting} onClick={()=>void save()}>{uncertain?'Vorgang erneut prüfen':'Fotoeinstellungen speichern'}</button></div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {HistoricalOrdersButton} from "./HistoricalOrders";
|
||||
import {CustomerInformation} from "./InformationDashboard";
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {UiLanguageButton} from './UiLanguage';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import {Field} from './FormElements';
|
||||
import {request} from './api';
|
||||
import {CustomerCostCenters} from './CustomerCostCenters';
|
||||
import {CustomerEmployees} from './CustomerEmployees';
|
||||
import {AccountTwoFactorDialog} from './AccountTwoFactor';
|
||||
import {CustomerExports} from './CustomerExports';
|
||||
import {CustomerStatistics} from './CustomerStatistics';
|
||||
import {CustomerStatement} from './CustomerStatement';
|
||||
import {CustomerOrder} from './CustomerOrder';
|
||||
import {CustomerMap} from './CustomerMap';
|
||||
import {CustomerOrderEntry} from './CustomerOrderEntry';
|
||||
import type {User} from './types';
|
||||
type Center={id:number;parent:number;label:string;blocked:number;visible:number};
|
||||
type Customer={id:number;label:string;base:number;centers:Center[]};
|
||||
type Options={canMap?:boolean;canEntry?:boolean;bourse?:boolean;name:string;customer:number;center:number;canRead:boolean;canStatement:boolean;canStatistics:boolean;canExport:boolean;canNavigate:boolean;status:number;descendants:boolean;from:string;to:string;customers:Customer[]};
|
||||
type Query={from:string;to:string;status:number;customer:number;center:number;descendants:boolean;search:string;stations:boolean;page:number;sort:string;descending:boolean};
|
||||
type Report={summary?:{count:number;amount:number;pendingCount?:number;basis?:string}|null;rows:{pricePending?:boolean;id:number;station:number;key:string;cells:Record<string,string>}[];columns:{key:string;label:string;type:string;align:"left"|"right"|"center"}[];page:number;limit:number;hasNext:boolean;dateApplied:boolean;warning:string};
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string|null)=>s?new Date(s).toLocaleString('de-DE'):'–';
|
||||
const ignoreDirty=(_v:boolean)=>{};
|
||||
export function CustomerPortal({user,onLogout}:{user:User;onLogout:()=>Promise<void>}){
|
||||
const uiText=useUiText("admin/jb_list.php");
|
||||
|
||||
const [archiveOpen,setArchiveOpen]=useState(false);
|
||||
const [languageOpen,setLanguageOpen]=useState(false),[infoOpen,setInfoOpen]=useState(false);
|
||||
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query|null>(null),[applied,setApplied]=useState<Query|null>(null),[report,setReport]=useState<Report|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[refresh,setRefresh]=useState(0),[job,setJob]=useState<number|null>(null),[filtersOpen,setFiltersOpen]=useState(true),[centersOpen,setCentersOpen]=useState(false),[employeesOpen,setEmployeesOpen]=useState(false),[statementOpen,setStatementOpen]=useState(false),[twoFactorOpen,setTwoFactorOpen]=useState(false),[statisticsOpen,setStatisticsOpen]=useState(false),[exportsOpen,setExportsOpen]=useState(false),[automatic,setAutomatic]=useState(true),[entryOpen,setEntryOpen]=useState(false),[entrySource,setEntrySource]=useState<number|null>(null),[mapOpen,setMapOpen]=useState(false);
|
||||
useEffect(()=>{const c=new AbortController();request<Options>('/customer-portal',{signal:c.signal}).then(o=>{if(c.signal.aborted)return;setOptions(o);setAutomatic(o.status!==2);const q={from:o.from,to:o.to,status:o.status,customer:o.customer,center:o.center,descendants:o.descendants,search:'',stations:false,page:0,sort:'jb_ordertime',descending:false};setQuery(q);if(o.canRead)setApplied(q);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[]);
|
||||
useEffect(()=>{if(!applied)return;const c=new AbortController();setBusy(true);setError('');request<Report>('/customer-portal/query',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(v=>{if(!c.signal.aborted)setReport(v);}).catch(e=>{if(!c.signal.aborted){setError(e.message);setReport(null);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied,refresh]);
|
||||
const locked=archiveOpen||infoOpen||languageOpen||mapOpen||busy||entryOpen||job!==null||centersOpen||employeesOpen||statementOpen||twoFactorOpen||statisticsOpen||exportsOpen;
|
||||
useEffect(()=>{if(!automatic||!applied||locked)return;const timer=setInterval(()=>{if(document.visibilityState==="visible")setRefresh(n=>n+1);},60000);return()=>clearInterval(timer);},[automatic,applied,locked]);
|
||||
async function centersChanged(){try{const o=await request<Options>('/customer-portal');setOptions(o);setQuery(q=>{if(!q)return q;const c=o.customers.find(c=>c.id===q.customer);return c?{...q,center:c.centers.some(cc=>cc.id===q.center)?q.center:c.base}:q;});setRefresh(n=>n+1);}catch(e){setError((e as Error).message);}}
|
||||
function show(e:FormEvent){e.preventDefault();if(query){const q={...query,page:0};setQuery(q);setApplied(q);if(q.status!==applied?.status)setAutomatic(q.status!==2);}}
|
||||
function page(n:number){if(applied){setApplied({...applied,page:n});setQuery(q=>q?{...q,page:n}:q);}}
|
||||
function sort(key:string){if(applied){const q={...applied,sort:key,descending:applied.sort===key?!applied.descending:false,page:0};setApplied(q);setQuery(old=>old?{...old,sort:q.sort,descending:q.descending,page:0}:old);}}
|
||||
async function logout(){if(locked)return;setBusy(true);try{await onLogout();}catch(e){setError((e as Error).message);setBusy(false);}}
|
||||
const customer=options?.customers.find(c=>c.id===query?.customer);
|
||||
return <main style={{maxWidth:1600,margin:'0 auto',padding:24}}><header className="admin-title"><div><h1>Kundenportal</h1><p>{options?.name||user.username} · {user.username}</p></div><div className="admin-toolbar">{options?.canRead&&<HistoricalOrdersButton disabled={locked} onActive={setArchiveOpen}/>}<CustomerInformation disabled={locked} onActive={setInfoOpen}/><UiLanguageButton disabled={locked} onActive={setLanguageOpen}/>{options?.canEntry&&<button className="button primary" disabled={locked} onClick={()=>{setEntrySource(null);setEntryOpen(true);}}>Auftrag erfassen</button>}<button className="button" disabled={locked||!options} onClick={()=>setCentersOpen(true)}>{uiText("Kostenstellen")}</button><button className="button" disabled={locked||!options} onClick={()=>setEmployeesOpen(true)}>Mitarbeiter / Eigenes Konto</button>{options?.canMap&&<button className="button" disabled={locked} onClick={()=>setMapOpen(true)}>Kartenübersicht</button>}{options?.canStatement&&<button className="button" disabled={locked} onClick={()=>setStatementOpen(true)}>Abrechnungsübersicht</button>}{options?.canExport&&<button className="button" disabled={locked} onClick={()=>setExportsOpen(true)}>{uiText("Export")}</button>}{options?.canStatistics&&<button className="button" disabled={locked} onClick={()=>setStatisticsOpen(true)}>{uiText("Statistik")}</button>}<button className="button" disabled={locked||!options} onClick={()=>setTwoFactorOpen(true)}>Zwei-Faktor-Anmeldung</button><button className="button" disabled={locked||!applied} onClick={()=>setRefresh(n=>n+1)}>{uiText("Aktualisieren")}</button><button className="button" disabled={locked} onClick={()=>void logout()}>{uiText("Abmelden")}</button></div></header>{error&&<p className="alert error" role="alert">{error}</p>}{!options&&!error&&<p>Kundenzugang wird geladen …</p>}{options&&query&&<section className="panel" style={{padding:20}}><h2>{options.bourse?"Auftragsbörse":uiText("Auftragslisten")}</h2>{options.canRead&&<label className="admin-check"><input type="checkbox" disabled={locked} checked={automatic} onChange={e=>setAutomatic(e.target.checked)}/>Automatisch alle 60 Sekunden aktualisieren</label>}<button className="button" disabled={locked} aria-expanded={filtersOpen} onClick={()=>setFiltersOpen(v=>!v)}>Suchfilter {filtersOpen?'einklappen':'ausklappen'}</button><form hidden={!filtersOpen} onSubmit={show}><fieldset disabled={locked}><div className="admin-toolbar"><Field label={uiText("Kunde")}><select value={query.customer} onChange={e=>{const id=Number(e.target.value),c=options.customers.find(c=>c.id===id)!;setQuery({...query,customer:id,center:c.base,page:0,sort:"jb_ordertime"});}}>{options.customers.map(c=><option value={c.id} key={c.id}>{c.label}</option>)}</select></Field><Field label={uiText("Kostenstelle")}><select value={query.center} onChange={e=>setQuery({...query,center:Number(e.target.value),page:0})}>{customer?.centers.map(c=><option value={c.id} key={c.id}>{c.label||`Kostenstelle ${c.id}`}</option>)}</select></Field>{options.canNavigate&&<label className="admin-check"><input type="checkbox" checked={query.descendants} onChange={e=>setQuery({...query,descendants:e.target.checked})}/>Unterkostenstellen einbeziehen</label>}</div>{options.canRead?<><div className="admin-toolbar"><Field label={uiText("Status")}><select value={query.status} onChange={e=>setQuery({...query,status:Number(e.target.value),sort:"jb_ordertime"})}>{options.bourse?<><option value={9}>Börsenauftragsangebote</option><option value={1}>Laufende Aufträge</option></>:<option value={0}>Offene Aufträge</option>}<option value={2}>Erledigte Aufträge</option></select></Field><Field label="Von"><input type="date" value={query.from} required={options.bourse||!query.search.trim()||query.stations} onChange={e=>setQuery({...query,from:e.target.value})}/></Field><Field label="Bis"><input type="date" value={query.to} required={options.bourse||!query.search.trim()||query.stations} onChange={e=>setQuery({...query,to:e.target.value})}/></Field><Field label="Auftrag, Kommissionsnummer oder Firma"><input value={query.search} maxLength={200} onChange={e=>setQuery({...query,search:e.target.value})}/></Field>{!options.bourse&&<label className="admin-check"><input type="checkbox" checked={query.stations} onChange={e=>setQuery({...query,stations:e.target.checked,sort:'jb_ordertime'})}/>Alle Stationen durchsuchen</label>}<button className="button primary">{uiText("Anzeigen")}</button></div><p className="form-hint">{!options.bourse&&query.search.trim()&&!query.stations?'Die Suche ersetzt den Datumsfilter.':'Die Auswahl ist auf den angegebenen Zeitraum begrenzt.'}{!options.bourse&&" Bei Stationssuche werden zusätzlich Bemerkungen und freigegebene Adressfelder durchsucht."}</p></>:<p>Für diesen Kundenzugang sind die Auftragslisten nicht freigegeben.</p>}</fieldset></form></section>}{busy&&<p>Aufträge werden geladen …</p>}{report&&<section className="panel" style={{padding:20}}>{report.warning&&<p claLine truncated
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type View={customer:number;label:string;revision:string;previousNumber:string;linked:boolean};
|
||||
type Draft={revision:string;previousNumber:string};
|
||||
type Preview={fingerprint:string;before:string;after:string;target:string;effect:string};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
export function CustomerPreviousNumber({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const base=`/master/customers/${customer}/previous-number`;
|
||||
const [view,setView]=useState<View|null>(null),[number,setNumber]=useState(''),[preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const pending=useRef<Command|null>(null),saving=useRef(false);const [discard,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setPreview(null);setError('');pending.current=null;void request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setView(v);setNumber(v.previousNumber);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,reload]);
|
||||
const dirty=!!view&&!done&&number!==view.previousNumber;
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(!dirty||await discard('Ungespeicherte Umfirmierungszuordnung verwerfen?'))onClose();}
|
||||
async function refresh(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Umfirmierungszuordnung verwerfen und neu laden?'))setReload(n=>n+1);}
|
||||
function change(value:string){setNumber(value);setPreview(null);pending.current=null;setError('');}
|
||||
async function check(){if(!view||busy||uncertain)return;setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>(`${base}/preview`,{revision:view.revision,previousNumber:number}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!view||!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:{revision:view.revision,previousNumber:number},fingerprint:preview.fingerprint};try{await post(base,pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){setPreview(null);pending.current=null;}setError((e as Error).message);}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Alte Kundennummer / Umfirmierung" onClose={()=>void close()}><div className="modal-body customer-previous-number">
|
||||
{view&&<h3>{view.label}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Umfirmierungsdaten werden geprüft …</p>}
|
||||
{done?<p role="status" className="admin-success">Umfirmierungszuordnung gespeichert.</p>:view&&<><fieldset disabled={busy||uncertain}><Field label="Alte ExtID nach Umfirmierung"><input value={number} maxLength={10} onChange={e=>change(e.target.value)}/></Field><button type="button" className="button" disabled={!number} onClick={()=>change('')}>Zuordnung entfernen</button></fieldset>
|
||||
<p className="form-hint">Bisher {view.linked?'mit einem Vorgängerkunden verknüpft':'keine Vorgängerverknüpfung'}. Geben Sie die vorherige Kundennummer ein. Vor dem Speichern wird geprüft, ob ein zugänglicher Vorgängerkunde vorhanden ist.</p>
|
||||
{preview&&<section className="customer-settings-preview"><h3>Geprüfte Änderung</h3><p>Bisher: <strong>{preview.before||'Keine alte Nummer'}</strong></p><p>Danach: <strong>{preview.after||'Keine alte Nummer'}</strong></p>{preview.target&&<p>Vorgänger: <strong>{preview.target}</strong></p>}<p>{preview.effect}</p></section>}
|
||||
{uncertain&&<p className="alert info" role="status">Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?'Schließen':'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void refresh()}>Neu laden</button>}{view&&!done&&(preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Zuordnung speichern'}</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{dialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {createPortal} from 'react-dom';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
type Reference={id:number;customer:number;number:string;name:string;headquarters:number;costCenter:string;postcode:string;city:string;warning:string};
|
||||
type Results={items:Reference[];total:number;offset:number;limit:number;headquarters:{id:number;label:string}[];selectedHeadquarters:number};
|
||||
const title=(r:Reference)=>[r.number,r.name,r.costCenter].filter(Boolean).join(' · ');
|
||||
export function CustomerReferenceField({kind,value,label,disabled,onChange}:{kind:'payer'|'parent';value:number;label:string;disabled:boolean;onChange:(id:number)=>void}){
|
||||
const [selected,setSelected]=useState<Reference|null>(null),[selectionError,setSelectionError]=useState(''),[open,setOpen]=useState(false);
|
||||
const base=`/master-data/customer-references/${kind}`;
|
||||
useEffect(()=>{setSelected(null);setSelectionError('');if(value<=0)return;const c=new AbortController();void request<Reference>(`${base}/${value}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setSelected(v);}).catch(e=>{if(!c.signal.aborted)setSelectionError(e.message);});return()=>c.abort();},[base,value]);
|
||||
return <div className="field customer-reference"><span>{label}</span><p role="status">{selected?title(selected):value>0?`Bestehende Zuordnung #${value}`:'Keine Zuordnung'}</p>{selected?.warning&&<small>{selected.warning}</small>}{selectionError&&<small role="alert">{selectionError} Die gespeicherte Zuordnung bleibt erhalten.</small>}<div className="button-row"><button type="button" className="button" disabled={disabled} onClick={()=>setOpen(true)}>{label} auswählen</button><button type="button" className="button" disabled={disabled||value<=0} onClick={()=>onChange(0)}>{label} entfernen</button></div><small className="form-hint">{kind==='payer'?'Vorgabe für den Rechnungszahler bei neuen Aufträgen. Bestehende Aufträge bleiben unverändert.':'Kundenzugänge des übergeordneten Kunden können mit entsprechender Portalberechtigung auf diesen Kunden zugreifen.'}</small>{open&&createPortal(<CustomerReferencePicker kind={kind} onClose={()=>setOpen(false)} onSelect={r=>{if(disabled)return;setSelected(r);onChange(r.id);setOpen(false);}}/>,document.body)}</div>;
|
||||
}
|
||||
function CustomerReferencePicker({kind,onClose,onSelect}:{kind:'payer'|'parent';onClose:()=>void;onSelect:(r:Reference)=>void}){
|
||||
const [q,setQ]=useState(''),[headquarters,setHeadquarters]=useState(0),[hidden,setHidden]=useState(false),[prospects,setProspects]=useState(false),[prefix,setPrefix]=useState(false),[offset,setOffset]=useState(0),[version,setVersion]=useState(0),[data,setData]=useState<Results|null>(null),[branches,setBranches]=useState<Results['headquarters']>([]),[busy,setBusy]=useState(true),[error,setError]=useState('');
|
||||
const criteria=useRef({q:'',headquarters:0,hidden:false,prospects:false,prefix:false});
|
||||
useEffect(()=>{const c=new AbortController();const p=new URLSearchParams({...Object.fromEntries(Object.entries(criteria.current).map(([k,v])=>[k,String(v)])),offset:String(offset)});setBusy(true);setError('');setData(null);void request<Results>(`/master-data/customer-references/${kind}?${p}`,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setData(v);setBranches(v.headquarters);setHeadquarters(v.selectedHeadquarters);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[kind,offset,version]);
|
||||
function search(){criteria.current={q,headquarters,hidden,prospects,prefix};setOffset(0);setVersion(v=>v+1);}
|
||||
return <Modal title={kind==='payer'?'Drittzahler auswählen':'Übergeordneten Kunden auswählen'} onClose={onClose} wide><div className="modal-body customer-reference-search"><div className="form-grid cols-3"><Field label="Firma, ExtID oder Kostenstelle"><input value={q} maxLength={200} onChange={e=>setQ(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'){e.preventDefault();search();}}}/></Field><Field label="Niederlassung"><select value={headquarters} disabled={busy} onChange={e=>setHeadquarters(Number(e.target.value))}>{branches.map(h=><option key={h.id} value={h.id}>{h.label}</option>)}</select></Field><Field label="Suchmodus"><select value={prefix?'prefix':'substring'} onChange={e=>setPrefix(e.target.value==='prefix')}><option value="substring">Teilwort</option><option value="prefix">Präfix</option></select></Field></div><div className="button-row"><label className="checkbox-label"><input type="checkbox" checked={hidden} onChange={e=>setHidden(e.target.checked)}/>Ausgeblendete anzeigen</label><label className="checkbox-label"><input type="checkbox" checked={prospects} onChange={e=>setProspects(e.target.checked)}/>Mit Interessenten</label><button type="button" className="button" disabled={busy} onClick={search}>Suchen</button></div>{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Kundenreferenzen werden geladen …</p>}{data&&<><div className="table-scroll"><table className="data-table"><thead><tr><th>ExtID</th><th>Firma</th>{kind==='payer'&&<th>Kostenstelle</th>}<th>Hinweis</th><th>Auswahl</th></tr></thead><tbody>{data.items.map(r=><tr key={r.id}><td>{r.number}</td><td>{r.name}{r.city&&<small>{r.postcode} {r.city}</small>}</td>{kind==='payer'&&<td>{r.costCenter}</td>}<td>{r.warning}</td><td><button type="button" className="button" onClick={()=>onSelect(r)}>Übernehmen · {r.number}{kind==='payer'?` · ${r.costCenter}`:''}</button></td></tr>)}</tbody></table></div>{data.items.length===0&&<p>Keine passenden Kunden gefunden.</p>}<div className="button-row"><button type="button" className="button" disabled={busy||offset===0} onClick={()=>setOffset(n=>Math.max(0,n-50))}>Vorherige Suchseite</button><span>{Math.min(offset+1,data.total)}–{Math.min(offset+50,data.total)} von {data.total}</span><button type="button" className="button" disabled={busy||offset+50>=data.total} onClick={()=>setOffset(n=>n+50)}>Nächste Suchseite</button></div></>}</div><div className="modal-footer"><button type="button" className="button" onClick={onClose}>Auswahl schließen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {Billing,type BillingSeed} from './Billing';
|
||||
import {MasterDataEditor} from './BusinessForms';
|
||||
type Detail={customer:number;label:string;headquarters:string;today:string;relatedCustomer:boolean;canOpenBilling:boolean;canEditMaster:boolean;billingUnavailableReason:string;periods:{key:string;label:string;from:string;to:string;net:number}[]};
|
||||
const euro=(v:number)=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(v);
|
||||
const date=(s:string)=>s.split('-').reverse().join('.');
|
||||
export function CustomerReportFinance({customer,refresh,onDirtyChange}:{customer:number;refresh:number;onDirtyChange:(value:boolean)=>void}){
|
||||
const [data,setData]=useState<Detail|null>(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[busy,setBusy]=useState(false),[master,setMaster]=useState(false),[billing,setBilling]=useState<BillingSeed|null>(null),[billingDirty,setBillingDirty]=useState(false),[billingBusy,setBillingBusy]=useState(false);
|
||||
const [discard,dialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setData(null);setError('');setBusy(true);request<Detail>(`/administration/reports/CUSTOMER/customers/${customer}/finance`).then(d=>{if(active)setData(d);}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[customer,refresh,reload]);
|
||||
useEffect(()=>{onDirtyChange(master||!!billing);return()=>onDirtyChange(false);},[master,billing,onDirtyChange]);
|
||||
async function closeBilling(){if(billingBusy)return;if(!billingDirty||await discard('Ungespeicherte Rechnungszuordnung verwerfen?')){setBilling(null);setBillingDirty(false);}}
|
||||
return <section className="report-customer-finance" aria-label="Kundenumsatz und Rechnungen"><div className="admin-title"><div><h3>Kundenumsatz und Rechnungen</h3>{data&&<p>{data.label} · {data.headquarters}</p>}</div><button className="button" disabled={busy} onClick={()=>setReload(v=>v+1)}>Umsatz aktualisieren</button></div>
|
||||
{busy&&<p role="status">Kundenumsatz wird geladen …</p>}{error&&<p role="alert" className="alert error">{error}</p>}{data&&<>
|
||||
<div className="report-revenue-grid">{data.periods.map(p=><article key={p.key}><span>{p.label}</span><strong>{euro(p.net)}</strong><small>{date(p.from)} – {date(p.to)}</small>{data.canOpenBilling&&<button className="button" aria-label={`Leistungen anzeigen: ${p.label}`} onClick={()=>setBilling({customer,from:p.from,to:p.to})}>Leistungen anzeigen</button>}</article>)}</div>
|
||||
<p className="form-hint">Netto-Auftragssummen nach Erledigungsdatum für diesen Kunden, ohne Unterkunden. {data.relatedCustomer?'Zuordnung über den verbundenen Kunden.':'Zuordnung über den Rechnungsempfänger.'} Die Jahreswerte sind unabhängig vom Berichtsfilter. Kostenaufteilung, Baraufträge und Maut können zu abweichenden Abrechnungswerten führen.</p>
|
||||
<div className="admin-row-actions">{data.canEditMaster&&<button className="button" onClick={()=>setMaster(true)}>Kundenstamm bearbeiten</button>}<button className="button" disabled={!data.canOpenBilling} onClick={()=>setBilling({customer,from:data.today.slice(0,8)+'01',to:data.today})}>Rechnungsübersicht</button></div>{!data.canOpenBilling&&<p className="form-hint">{data.billingUnavailableReason}</p>}
|
||||
</>}
|
||||
{master&&<MasterDataEditor kind="customers" id={customer} onClose={()=>setMaster(false)} onSaved={()=>{setReload(v=>v+1);}}/>}
|
||||
{billing&&<Modal title={`Rechnungsübersicht · ${data?.label??customer}`} wide onClose={()=>void closeBilling()}><div className="admin-dialog-body report-customer-billing"><Billing refresh={0} seed={billing} onDirtyChange={setBillingDirty} onBusyChange={setBillingBusy}/></div><div className="modal-footer"><button className="button" disabled={billingBusy} onClick={()=>void closeBilling()}>Zurück zu den Berichten</button></div></Modal>}{dialog}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type Values={sender:string;to:string;cc:string;bcc:string};
|
||||
type View={customer:number;label:string;revision:string;configured:boolean;ready:boolean;values:Values;warnings:string[];impact:string};
|
||||
type Draft={revision:string;action:'SET'|'REMOVE';values:Values|null};
|
||||
type Preview={fingerprint:string;customer:string;before:Values;after:Values;remove:boolean;impact:string};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string;sharedUseConfirmed:boolean};
|
||||
const labels:Record<keyof Values,string>={sender:'Absender',to:'An',cc:'Cc',bcc:'Bcc'};
|
||||
const empty:Values={sender:'',to:'',cc:'',bcc:''};
|
||||
export function CustomerRollcardMailSettings({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${customer}/rollcard-mail-settings`;
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values>(empty),[remove,setRemove]=useState(false),[preview,setPreview]=useState<Preview|null>(null),[confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(true),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[error,setError]=useState(''),[reload,setReload]=useState(0);
|
||||
const pending=useRef<Command|null>(null),saving=useRef(false);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setView(null);setPreview(null);setError('');setRemove(false);setConfirmed(false);pending.current=null;
|
||||
void request<View>(base,{signal:c.signal}).then(v=>{if(!c.signal.aborted){setView(v);setValues(v.values);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base,reload]);
|
||||
const dirty=!!view&&!done&&(remove?view.configured:JSON.stringify(values)!==JSON.stringify(view.values));
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(!dirty||await discard('Ungespeicherte Rollkarten-Mailadressen verwerfen?'))onClose();}
|
||||
async function refresh(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Rollkarten-Mailadressen verwerfen und neu laden?'))setReload(n=>n+1);}
|
||||
function resetPreview(){setPreview(null);setConfirmed(false);pending.current=null;setError('');}
|
||||
function draft():Draft{if(!view)throw Error('Bitte zuerst die Kundenvorgaben laden.');return {revision:view.revision,action:remove?'REMOVE':'SET',values:remove?null:values};}
|
||||
async function check(){if(busy||uncertain)return;setBusy(true);resetPreview();try{setPreview(await post<Preview>(`${base}/preview`,draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||!confirmed||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint,sharedUseConfirmed:true};
|
||||
try{await post(base,pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry)resetPreview();setError((e as Error).message);}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Rollkarten-Mailadressen" wide onClose={()=>void close()}><div className="admin-dialog-body modal-body customer-rollcard-mail-settings">
|
||||
{view&&<h3>{view.label} · #{customer}</h3>}
|
||||
<p>Diese Kundenvorgaben verwenden der manuelle Rollkartenversand in NG und die Altanwendung. Allgemeine Mailverteiler werden nicht als Ersatz übernommen.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
{busy&&<p role="status">Mailvorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Rollkarten-Mailvorgaben gespeichert. Für einen manuellen Versand bitte eine neue Versandvorschau erstellen.</p>:view&&<>
|
||||
{view.warnings.map((w,i)=><p className="alert info" key={i}>{w}</p>)}
|
||||
<fieldset disabled={busy||uncertain}><label className="admin-check"><input type="checkbox" checked={remove} disabled={!view.configured} onChange={e=>{setRemove(e.target.checked);resetPreview();}}/>Alle Rollkarten-Mailvorgaben dieses Kunden entfernen</label>
|
||||
{remove?<p>Absender, An, Cc und Bcc werden entfernt. Ohne kundenspezifischen Absender ist auch der bestehende automatische PHP-Rollkartenversand für diesen Kunden nicht möglich.</p>:<><p className="form-hint">Reine E-Mail-Adressen ohne Anzeigenamen; mehrere Empfänger durch Komma oder Semikolon trennen. Höchstens 100 Empfänger insgesamt. Doppelte Adressen werden in der Vorschau zusammengeführt.</p><div className="form-grid cols-2">{(Object.keys(labels) as (keyof Values)[]).map(key=><Field key={key} label={labels[key]+(key==='sender'||key==='to'?' *':'')}><input type="text" value={values[key]} maxLength={key==='sender'?254:10000} onChange={e=>{setValues(v=>({...v,[key]:e.target.value}));resetPreview();}}/></Field>)}</div></>}
|
||||
</fieldset>
|
||||
<p className="alert info">{view.impact}</p>
|
||||
{preview&&<section className="customer-settings-preview"><h3>Geprüfte Änderung</h3><div className="table-wrap"><table><thead><tr><th>Angabe</th><th>Bisher</th><th>Nach dem Speichern</th></tr></thead><tbody>{(Object.keys(labels) as (keyof Values)[]).map(key=><tr key={key}><th>{labels[key]}</th><td className="mail-address-value">{preview.before[key]||'–'}</td><td className="mail-address-value">{preview.after[key]||'–'}</td></tr>)}</tbody></table></div><label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy||uncertain} onChange={e=>setConfirmed(e.target.checked)}/>Adressen und Auswirkungen auf automatischen Versand und Auftragszuordnung geprüft</label></section>}
|
||||
{uncertain&&<p role="status" className="alert info">Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen; die Änderung wird nicht doppelt ausgeführt.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void refresh()}>Neu laden</button>}{view&&!done&&(preview?<button className="button primary" disabled={busy||!confirmed} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':'Mailvorgaben speichern'}</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {put,request} from './api';
|
||||
import {ErrorMessage,uncertainWrite} from './BusinessForms';
|
||||
import {ReportFormLayout,calculateForm,type FormCalculation} from './ReportForms';
|
||||
|
||||
type Option={id:number;label:string};
|
||||
type Options={services:Option[];prices:Option[]};
|
||||
type Entry={code:string;text:string;mail:string};
|
||||
type Definition={id:number;label:string;type:string;editable:boolean;calculated?:boolean;snapshot?:boolean;lookup?:boolean;choices?:{value:string;label:string}[]};
|
||||
type Detail={calculations?:FormCalculation[];templateWarning?:string;hasUnsavedDefaults?:boolean;revision:string;schemaRevision:string;entries?:Entry[];footer?:string;maxEntries?:number;title?:string;configured?:boolean;fields?:Definition[];values?:Record<string,string>;html?:string;layout?:string;unsupported?:string[]};
|
||||
type Draft={entries:Entry[];footer:string;values:Record<string,string>};
|
||||
type History={fieldId:number;value:string;time:string};
|
||||
const draftOf=(d:Detail):Draft=>({entries:d.entries??[],footer:d.footer??'',values:d.values??{}});
|
||||
|
||||
export function CustomerServiceDocuments({id,onClose}:{id:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${id}/service-documents`;
|
||||
const [options,setOptions]=useState<Options|null>(null),[selection,setSelection]=useState('reasons/0'),[detail,setDetail]=useState<Detail|null>(null),[draft,setDraft]=useState<Draft>({entries:[],footer:'',values:{}});
|
||||
const [busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[preview,setPreview]=useState(false),[history,setHistory]=useState<History[]|null>(null);
|
||||
const pending=useRef<Record<string,unknown>|null>(null),frame=useRef<HTMLIFrameElement>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const [kind]=selection.split('/');const dirty=!!detail&&JSON.stringify(draft)!==JSON.stringify(draftOf(detail));
|
||||
async function load(){const d=await request<Detail>(`${base}/${selection}`);setDetail(d);setDraft(draftOf(d));}
|
||||
useEffect(()=>{let active=true;request<Options>(base).then(o=>{if(active)setOptions(o);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[base]);
|
||||
useEffect(()=>{let active=true;setBusy(true);setDetail(null);setHistory(null);setPreview(false);setError('');request<Detail>(`${base}/${selection}`).then(d=>{if(active){setDetail(d);setDraft(draftOf(d));}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[base,selection]);
|
||||
async function close(){if(!busy&&!uncertain&&(!dirty||await discard('Ungespeicherte Formulareingaben verwerfen?')))onClose();}
|
||||
async function choose(value:string){if(!busy&&!uncertain&&(!dirty||await discard('Ungespeicherte Formulareingaben verwerfen?'))){pending.current=null;setDetail(null);setBusy(true);setSelection(value);setNotice('');}}
|
||||
function change(patch:Partial<Draft>){pending.current=null;setNotice('');setDraft(d=>({...d,...patch}));}
|
||||
function row(index:number,patch:Partial<Entry>){change({entries:draft.entries.map((e,i)=>i===index?{...e,...patch}:e)});}
|
||||
function move(index:number,direction:number){const entries=[...draft.entries];[entries[index],entries[index+direction]]=[entries[index+direction],entries[index]];change({entries});}
|
||||
async function save(){if(!detail)return;setBusy(true);setError('');try{pending.current??={requestId:crypto.randomUUID(),revision:detail.revision,schemaRevision:detail.schemaRevision,...(kind==='prices'?{values:draft.values}:{entries:draft.entries,footer:draft.footer})};await put(`${base}/${selection}`,pending.current);pending.current=null;setUncertain(false);setNotice('Gespeichert.');setHistory(null);await load();}catch(e){if(pending.current)setUncertain(uncertainWrite(e));setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const title=kind==='protocol'?'Abnahmeprotokoll':kind==='reasons'?'Abliefergründe':detail?.title||'Kundenpreisformular';
|
||||
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>${title.replace(/[<>&"]/g,'')}</title></head><body>${detail?.html??''}</body></html>`;
|
||||
return <><Modal title={`Kundenformulare und Abliefergründe · Kunde ${id}`} wide onClose={()=>void close()}><div className="admin-dialog-body customer-documents">
|
||||
<div className="admin-toolbar"><Field label="Formular / Liste"><select disabled={busy||uncertain} value={selection} onChange={e=>void choose(e.target.value)}><optgroup label="Abliefergründe">{[0,1,2,3].map(n=><option value={`reasons/${n}`} key={n}>Abliefergründe · Liste {n+1}</option>)}</optgroup>{!!options?.services.length&&<optgroup label="Abnahmeprotokolle">{options.services.map(s=><option key={s.id} value={`protocol/${s.id}`}>Abnahmeprotokoll · {s.label}</option>)}</optgroup>}{!!options?.prices.length&&<optgroup label="Preisformulare">{options.prices.map(p=><option key={p.id} value={`prices/${p.id}`}>{p.label}</option>)}</optgroup>}</select></Field><button className="button" disabled={busy||uncertain} onClick={async()=>{if(dirty&&!await discard('Ungespeicherte Formulareingaben verwerfen?'))return;pending.current=null;setBusy(true);setError('');setNotice('');try{await load();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}}>Neu laden</button></div>
|
||||
{options&&!options.services.length&&<p className="form-hint">Für Abnahmeprotokolle sind noch keine Services im allgemeinen Katalog eingerichtet.</p>}
|
||||
<ErrorMessage message={error}/>{notice&&<p className="admin-success" role="status">{notice}</p>}{!detail&&busy&&<p>Formular wird geladen …</p>}
|
||||
{detail&&<><h3>{title}</h3>{detail.fields?.some(f=>f.snapshot)&&<p className="form-hint">Vorbelegte Angaben können Sie für dieses Formular anpassen. Die ursprünglichen Stammdaten bleiben erhalten.</p>}{detail.templateWarning&&<p className="report-warning" role="alert">{detail.templateWarning}</p>}{detail.hasUnsavedDefaults&&<p className="form-hint">Die vorbelegten oder berechneten Angaben wurden noch nicht gespeichert.</p>}<fieldset disabled={busy||uncertain||!!detail.templateWarning} className="business-fieldset">
|
||||
{kind==='prices'?<>{!detail.configured?<p role="status">Für dieses Preisformular sind noch keine Felder eingerichtet. Die Definitionen können unter Formulare zugeordnet werden.</p>:<>
|
||||
{!!detail.unsupported?.length&&<details><summary>Spezialfelder (unverändert erhalten)</summary>{detail.fields?.filter(f=>!f.editable).map(f=><p className="message-text" key={f.id}><strong>{f.label}</strong><br/>{detail.values?.[f.id]||'Kein gespeicherter Wert'}</p>)}</details>}
|
||||
<div className="admin-row-actions"><button className="button" disabled={dirty} onClick={()=>setPreview(v=>!v)}>{preview?'Formular bearbeiten':'Druckvorschau'}</button>{preview&&<button className="button" onClick={()=>frame.current?.contentWindow?.print()}>Drucken / PDF</button>}<button className="button" onClick={async()=>{setBusy(true);setError('');try{setHistory(await request<History[]>(`${base}/${selection}/history`));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}}>Feldhistorie</button></div>
|
||||
{preview?<iframe ref={frame} title={`${title} – Vorschau`} className="newsletter-preview" sandbox="allow-same-origin allow-modals" srcDoc={documentHtml}/>:<ReportFormLayout html={detail.layout??''} fields={detail.fields??[]} values={draft.values} onChange={(key,value)=>change({values:calculateForm({...draft.values,[key]:value},detail.calculations)})} lookup={`${base}/${selection}/fields`}/>}
|
||||
</>}</>:<>
|
||||
<p>{kind==='protocol'?'Fragen und Abschlusstext für den ausgewählten Service. Die Mail-Bedingung wird mit jeder Frage gespeichert.':'Vier unabhängige Listen für das Fahrergerät. Code und Text werden in der angezeigten Reihenfolge gespeichert.'}</p>
|
||||
<div className="table-wrap"><table><thead><tr><th>{uiText("Nr.")}</th>{kind==='reasons'&&<th>Code</th>}<th>{kind==='protocol'?'Frage':uiText("Text")}</th>{kind==='protocol'&&<th>Mail-Bedingung</th>}<th>Aktionen</th></tr></thead><tbody>{draft.entries.map((e,index)=><tr key={index}><td>{index+1}</td>{kind==='reasons'&&<td><input aria-label={`Code ${index+1}`} maxLength={50} value={e.code} onChange={ev=>row(index,{code:ev.target.value})}/></td>}<td><input aria-label={`${kind==='protocol'?'Frage':'Text'} ${index+1}`} maxLength={1000} value={e.text} onChange={ev=>row(index,{text:ev.target.value})}/></td>{kind==='protocol'&&<td><select aria-label={`Mail-Bedingung ${index+1}`} value={e.mail} onChange={ev=>row(index,{mail:ev.target.value})}><option value="">Keine Mail</option><option value="1">Mail bei Ja</option><option value="2">Mail bei Nein</option></select></td>}<td><div className="admin-row-actions"><button className="button" aria-label={`Eintrag ${index+1} nach oben`} disabled={!index} onClick={()=>move(index,-1)}>↑</button><button className="button" aria-label={`Eintrag ${index+1} nach unten`} disabled={index===draft.entries.length-1} onClick={()=>move(index,1)}>↓</button><button className="button" aria-label={`Eintrag ${index+1} entfernen`} onClick={()=>change({entries:draft.entries.filter((_,i)=>i!==index)})}>Entfernen</button></div></td></tr>)}</tbody></table></div>
|
||||
{!draft.entries.length&&<p>Noch keine Einträge vorhanden.</p>}<button className="button" disabled={draft.entries.length>=(detail.maxEntries??21)} onClick={()=>change({entries:[...draft.entries,{code:'',text:'',mail:''}]})}>{kind==='protocol'?'Frage ergänzen':'Abliefergrund ergänzen'}</button>
|
||||
{kind==='protocol'&&<Field label="Abschlusstext" wide><textarea rows={5} value={draft.footer} onChange={e=>change({footer:e.target.value})}/></Field>}
|
||||
</>}
|
||||
</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>{detail.fields?.find(f=>f.id===h.fieldId)?.label} · {new Date(h.time).toLocaleString('de-DE')}</strong><br/>{h.value}</p>)}</section>}</>}
|
||||
{uncertain&&<p role="status">Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button" disabled={busy||uncertain||!dirty} onClick={async()=>{if(detail&&await discard('Formulareingaben zurücksetzen?')){pending.current=null;setDraft(draftOf(detail));}}}>{uiText("Zurücksetzen")}</button><button className="button primary" disabled={busy||!detail||!!detail.templateWarning||(!dirty&&!uncertain&&!detail.hasUnsavedDefaults)||(kind==='prices'&&!detail.configured)} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':uiText("Speichern")}</button></div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {ErrorMessage,uncertainWrite} from './BusinessForms';
|
||||
|
||||
type Options={revision:string;canImport:boolean;center:string;intervals:string;calculationError:string};
|
||||
type Item={number:number;zone:number;postal:number;code:string;country:string;district:string;express:number;surcharge:number;extra:number};
|
||||
type Preview={description:string;rows:Item[];affected:Record<string,string|number>[];planRevision:string;count:number};
|
||||
type Command={requestId:string;revision:string;mode:string;csv:string;planRevision:string};
|
||||
const formats:Record<string,{name:string;columns:string;example:string}>={
|
||||
ZONE:{name:'Zonenzuordnungen',columns:'Zonennummer;Land;PLZ;Express;Zonenaufschlag;Zusatzwert 3',example:'0;DE;10115;1,50;3,25;0'},
|
||||
ZONEAGIO:{name:'Zonenaufschläge',columns:'PLZ;Zonenaufschlag',example:'10115;3,25'},
|
||||
RADIUSZONE:{name:'Radiuszuordnungen',columns:'Radiusbereich;PLZ;Bezirk/Stadt',example:'1;10115;Mitte'}
|
||||
};
|
||||
const money=(n:unknown)=>Number(n).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
export function CustomerServiceImports({id,mode:initial,source,onClose,onSaved}:{id:number;mode:string;source?:{folder:string;name:string;revision:string};onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${id}/service-imports`;
|
||||
const [options,setOptions]=useState<Options|null>(null),[mode,setMode]=useState(initial),[csv,setCsv]=useState(''),[filename,setFilename]=useState(''),[encoding,setEncoding]=useState('utf-8');
|
||||
const [preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[page,setPage]=useState(0),[before,setBefore]=useState(false);
|
||||
const pending=useRef<Command|null>(null),file=useRef<File|null>(null),mounted=useRef(true);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
async function load(){setOptions(await request<Options>(base));}
|
||||
async function initialLoad(){await load();if(source){const query=new URLSearchParams({...source,format:initial});const data=await request<{csv:string;filename:string;encoding:string}>(`/master-documents/customers/${id}/import-source?${query}`);if(mounted.current){setCsv(data.csv);setFilename(data.filename);setEncoding(data.encoding==='UTF-8'?'utf-8':'windows-1252');}}}
|
||||
useEffect(()=>{mounted.current=true;setBusy(true);void initialLoad().catch(e=>setError(e.message)).finally(()=>setBusy(false));return()=>{mounted.current=false;};},[id]);
|
||||
const calculate=mode==='CALCULATE',dirty=!!csv||!!preview;
|
||||
async function close(){if(busy||uncertain)return;if(!dirty||await discard('Unübernommenen Import bzw. Radiusvorschau verwerfen?'))onClose();}
|
||||
function invalidate(){setPreview(null);pending.current=null;setPage(0);setBefore(false);setError('');}
|
||||
async function readFile(f:File|null,charset:string){if(!f)return;invalidate();setBusy(true);try{if(f.size>1_000_000)throw new Error('Die Datei darf höchstens 1 MB groß sein.');const buffer=await f.arrayBuffer();const text=new TextDecoder(charset,{fatal:true}).decode(buffer);if(mounted.current){setCsv(text);setFilename(f.name);file.current=f;}}catch(e){setCsv('');setFilename('');file.current=f;setError(e instanceof TypeError?'Die Datei passt nicht zum gewählten Zeichensatz. Bei älteren Dateien bitte Windows-1252 wählen.':(e as Error).message);}finally{setBusy(false);}}
|
||||
async function prepare(){if(!options)return;setBusy(true);setError('');try{const command={requestId:crypto.randomUUID(),revision:options.revision,mode,csv:calculate?'':csv,planRevision:''};const p=await post<Preview>(`${base}/preview`,command);pending.current={...command,planRevision:p.planRevision};setPreview(p);setPage(0);setBefore(false);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!pending.current)return;setBusy(true);setError('');try{await post(base,pending.current);pending.current=null;setUncertain(false);onSaved();}catch(e){setUncertain(uncertainWrite(e));setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const visible=before?preview?.affected:preview?.rows;
|
||||
return <><Modal title={calculate?'Kundenradien neu berechnen':'Kunden-Zuordnungen importieren'} wide onClose={()=>void close()}><div className="modal-body admin-dialog-body customer-imports"><ErrorMessage message={error}/>
|
||||
{!options&&<p>Importdaten werden geladen …</p>}{options&&<><fieldset className="business-fieldset" disabled={busy||uncertain}>
|
||||
{!calculate&&<><div className="form-grid cols-2"><Field label="Importart"><select value={mode} onChange={e=>{setMode(e.target.value);invalidate();}}>{Object.entries(formats).map(([key,f])=><option value={key} key={key}>{f.name}</option>)}</select></Field><Field label="Zeichensatz"><select value={encoding} onChange={e=>{setEncoding(e.target.value);void readFile(file.current,e.target.value);}}><option value="utf-8">UTF-8</option><option value="windows-1252">Windows-1252 (alte Dateien)</option></select></Field></div>
|
||||
<p>CSV mit Semikolon, ohne Kopfzeile. Dezimalkomma oder Dezimalpunkt sind möglich; eine leere Abschlussspalte ist erlaubt. Maximal 5000 Datenzeilen.</p><p><strong>Spalten:</strong> {formats[mode].columns}<br/><strong>Beispiel:</strong> <code>{formats[mode].example}</code></p>
|
||||
<Field label="CSV-Datei"><input type="file" accept=".csv,.txt,text/csv,text/plain" onChange={e=>void readFile(e.target.files?.[0]??null,encoding)}/></Field>{filename&&<p>{filename}</p>}
|
||||
<Field label="CSV-Inhalt"><textarea rows={6} maxLength={1_000_000} spellCheck={false} value={csv} onChange={e=>{setCsv(e.target.value);file.current=null;setFilename('');invalidate();}}/></Field>
|
||||
{!options.canImport&&<p>Für den Import fehlt die Berechtigung „Datenimport“.</p>}
|
||||
</>}
|
||||
{calculate&&<><p>Die Orts- und Bezirkskoordinaten werden nach ihrer Luftlinienentfernung zur Abhol-/Lieferadresse der Hauptkostenstelle zugeordnet. Falls diese fehlt, wird die Firmenanschrift verwendet.</p><dl><dt>Ausgangsadresse</dt><dd>{options.center||'Nicht eindeutig vorhanden'}</dd><dt>Hinterlegte Radiusintervalle (km)</dt><dd>{options.intervals||'Nicht vorhanden'}</dd></dl><p>Bereiche werden in der Reihenfolge der Intervallschlüssel ab 1 nummeriert. Die äußere Grenze ist ausgeschlossen, die innere eingeschlossen.</p>{options.calculationError&&<p role="status" className="customer-settings-preview">{options.calculationError}</p>}</>}
|
||||
</fieldset>
|
||||
{preview&&<section className="customer-settings-preview"><h3>Änderungsvorschau</h3><p>{preview.description}</p><div className="admin-toolbar"><button className={`button ${!before?'primary':''}`} disabled={busy||uncertain} onClick={()=>{setBefore(false);setPage(0);}}>Neue Werte ({preview.count})</button><button className={`button ${before?'primary':''}`} disabled={busy||uncertain} onClick={()=>{setBefore(true);setPage(0);}}>Betroffene bisherige Werte ({preview.affected.length})</button></div>
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Bereich / Zone</th><th>PLZ / Land</th>{calculate||mode==='RADIUSZONE'?<><th>Bezirk / Stadt</th><th>{uiText("Preis")}</th></>:<><th>Express</th><th>Zonenaufschlag</th><th>Zusatzwert 3</th></>}</tr></thead><tbody>{before?preview.affected.slice(page*100,page*100+100).map((r,i)=><tr key={i}><td>{r.srvr_radiusarea_no??r.number}</td><td>{r.srvr_zipcode??r.code} / {r.country??'DE'}</td>{calculate||mode==='RADIUSZONE'?<><td>{r.srvr_city_district}</td><td>{money(r.srvr_price_1)} €</td></>:<><td>{money(r.srvzm_mt_1)} €</td><td>{money(r.srvzm_mt_2)} €</td><td>{money(r.srvzm_mt_3)} €</td></>}</tr>):preview.rows.slice(page*100,page*100+100).map((r,i)=><tr key={i}><td>{r.number}</td><td>{r.code} / {r.country}</td>{calculate||mode==='RADIUSZONE'?<><td>{r.district}</td><td>0,00 €</td></>:<><td>{money(r.express)} €</td><td>{money(r.surcharge)} €</td><td>{money(r.extra)} €</td></>}</tr>)}</tbody></table></div>{visible?.length===0&&<p>Keine bisherigen Werte betroffen.</p>}{!!visible&&visible.length>100&&<div className="admin-row-actions"><button className="button" disabled={!page} onClick={()=>setPage(p=>p-1)}>Zurück</button><span>Seite {page+1} {uiText(" von ")}{Math.ceil(visible.length/100)}</span><button className="button" disabled={(page+1)*100>=visible.length} onClick={()=>setPage(p=>p+1)}>Weiter</button></div>}</section>}
|
||||
{uncertain&&<p>Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>Abbrechen</button><button className="button" disabled={busy||uncertain} onClick={()=>{invalidate();setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));}}>Bestand neu laden</button>{!preview?<button className="button primary" disabled={busy||!options||(!calculate&&!options.canImport)||(!calculate&&!csv.trim())||!!(calculate&&options.calculationError)} onClick={()=>void prepare()}>{busy?'Wird geprüft …':calculate?'Radien berechnen und prüfen':'Import prüfen'}</button>:<button className="button primary" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':calculate?'Radien übernehmen':'Import übernehmen'}</button>}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {ErrorMessage,uncertainWrite} from './BusinessForms';
|
||||
import {CustomerServiceImports} from './CustomerServiceImports';
|
||||
|
||||
type Zone={id:number;number:number;name:string;prices:number[]};
|
||||
type Mapping={zoneId:number;postalId:number;postalCode:string;country:string;express:number;surcharge:number;extra:number};
|
||||
type Radius={id:number;postalCode:string;district:string;number:number;price:number};
|
||||
type Entry={id:number;type:string;sort:number;name:string;mapped:string};
|
||||
type Data={revision:string;canManageZones:boolean;canDeleteZones:boolean;canManageDayTimes:boolean;canManageDiscounts:boolean;zones:Zone[];mappings:Mapping[];radii:Radius[];entries:Entry[];defaults:Entry[]};
|
||||
type Draft={action:string;id:number;zoneId:number;postalCode:string;country:string;district:string;number:number;name:string;type:string;from:string;to:string;mapped:string};
|
||||
type Command=Draft&{requestId:string;revision:string};
|
||||
const labels:Record<string,string>={day_time:'Tageszeiten',delivery_time:'Auslieferarten',discount_mode:'Rabattaktionen',sms_time:'SMS-Versandzeit',cartage_time:'Rollkarten-Versandzeit'};
|
||||
const titles:Record<string,string>={ZONE_ADD:'Servicezone anlegen',ZONE_RENAME:'Servicezone umbenennen',ZONE_DELETE:'Servicezone löschen',MAPPING_ADD:'PLZ zuordnen',MAPPING_REMOVE:'PLZ-Zuordnung entfernen',RADIUS_ADD:'Radiuszuordnung anlegen',RADIUS_UPDATE:'Radiuszuordnung bearbeiten',RADIUS_REMOVE:'Radiuszuordnung entfernen',META_ADD:'Eintrag ergänzen',META_UPDATE:'Eintrag bearbeiten',META_REMOVE_LAST:'Letzten Eintrag entfernen',META_DEFAULTS:'Standardwerte übernehmen',TIME_SET:'Versandzeit setzen'};
|
||||
const money=(n:number)=>Number(n).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:4});
|
||||
const blank=(action:string,patch:Partial<Draft>={}):Draft=>({action,id:0,zoneId:0,postalCode:'',country:'DE',district:'',number:1,name:'',type:'',from:'',to:'',mapped:'',...patch});
|
||||
|
||||
export function CustomerServiceSettings({id,onClose}:{id:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/master/customers/${id}/service-settings`;
|
||||
const [data,setData]=useState<Data|null>(null),[tab,setTab]=useState('zones'),[query,setQuery]=useState(''),[page,setPage]=useState(0);
|
||||
const [draft,setDraft]=useState<Draft|null>(null),[preview,setPreview]=useState<{description:string}|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false);
|
||||
const [importMode,setImportMode]=useState<string|null>(null);
|
||||
const original=useRef(''),pending=useRef<Command|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
async function load(){setData(await request<Data>(base));}
|
||||
useEffect(()=>{setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));},[id]);
|
||||
const dirty=!!draft&&(JSON.stringify(draft)!==original.current||!!preview);
|
||||
async function closeEditor(){if(busy||uncertain)return;if(!dirty||await discard('Ungespeicherte Servicevorgaben verwerfen?')){setDraft(null);setPreview(null);pending.current=null;}}
|
||||
function edit(action:string,patch:Partial<Draft>={}){const value=blank(action,patch);original.current=JSON.stringify(value);setDraft(value);setPreview(null);setError('');pending.current=null;}
|
||||
function change(patch:Partial<Draft>){setDraft(d=>d?{...d,...patch}:null);setPreview(null);pending.current=null;}
|
||||
async function prepare(){if(!draft||!data)return;setBusy(true);setError('');try{const command={...draft,requestId:crypto.randomUUID(),revision:data.revision};const p=await post<{description:string}>(`${base}/preview`,command);pending.current=command;setPreview(p);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!pending.current)return;setBusy(true);setError('');try{await post(base,pending.current);pending.current=null;setUncertain(false);setDraft(null);setPreview(null);setNotice('Servicevorgaben gespeichert.');await load();}catch(e){if(pending.current)setUncertain(uncertainWrite(e));setError((e as Error).message);}finally{setBusy(false);}}
|
||||
function editEntry(e:Entry){edit('META_UPDATE',{id:e.id,type:e.type,name:e.name,mapped:e.mapped,from:e.type==='day_time'?e.mapped.slice(0,5):'',to:e.type==='day_time'?e.mapped.slice(8,13):e.type==='delivery_time'?e.mapped:''});}
|
||||
const filteredMappings=data?.mappings.filter(m=>`${m.postalCode} ${m.country} ${data.zones.find(z=>z.id===m.zoneId)?.name}`.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de')))??[];
|
||||
const filteredRadii=data?.radii.filter(r=>`${r.postalCode} ${r.district} ${r.number}`.toLocaleLowerCase('de').includes(query.toLocaleLowerCase('de')))??[];
|
||||
const count=tab==='zones'?filteredMappings.length:filteredRadii.length;
|
||||
const deletion=draft&&['ZONE_DELETE','MAPPING_REMOVE','RADIUS_REMOVE','META_REMOVE_LAST','META_DEFAULTS'].includes(draft.action);
|
||||
return <><Modal title={`Zonen und Servicevorgaben · Kunde ${id}`} wide onClose={()=>{if(!draft&&!importMode&&!busy&&!uncertain)onClose();}}><div className="admin-dialog-body customer-settings">
|
||||
{!draft&&<ErrorMessage message={error}/>} {notice&&<p role="status" className="admin-success">{notice}</p>}
|
||||
<div className="admin-toolbar">{[['zones','Servicezonen'],['radii','Radiuszuordnungen'],['times','Zeiten und Aktionen']].map(([key,title])=><button className={`button ${tab===key?'primary':''}`} key={key} disabled={busy} onClick={()=>{setTab(key);setQuery('');setPage(0);}}>{title}</button>)}<button className="button" disabled={busy} onClick={()=>{setBusy(true);setError('');void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));}}>Neu laden</button></div>
|
||||
{!data&&<p>Servicevorgaben werden geladen …</p>}
|
||||
{data&&tab==='zones'&&<><p>Kundenspezifische Zonen mit festen Nummern. Bestehende Zonenpreise und PLZ-Zuschläge werden beim Umbenennen erhalten.</p>
|
||||
{data.canManageZones&&<button className="button" onClick={()=>edit('ZONE_ADD')}>Neue Servicezone</button>}
|
||||
<div className="table-wrap"><table><thead><tr><th>{uiText("Nr.")}</th><th>Servicezone</th><th>Preise 0 / 1 / 2 / 3</th><th>Aktionen</th></tr></thead><tbody>{data.zones.map(z=><tr key={z.id}><td>{z.number}</td><td>{z.name}</td><td>{z.prices.map(money).join(' / ')} €</td><td><div className="admin-row-actions">{data.canManageZones&&<button className="button" onClick={()=>edit('ZONE_RENAME',{id:z.id,name:z.name})}>Umbenennen</button>}{data.canDeleteZones&&<button className="button" onClick={()=>edit('ZONE_DELETE',{id:z.id})}>{uiText("Löschen")}</button>}</div></td></tr>)}</tbody></table></div>{!data.zones.length&&<p>Noch keine Servicezonen vorhanden.</p>}
|
||||
<h3>PLZ-Zuordnungen</h3><button className="button" onClick={()=>setImportMode('ZONE')}>CSV-Zuordnungen importieren</button> <button className="button" disabled={!data.zones.length} onClick={()=>edit('MAPPING_ADD',{zoneId:data.zones[0]?.id??0})}>PLZ zuordnen</button>
|
||||
</>}
|
||||
{data&&tab==='radii'&&<><p>Deutsche PLZ und Bezirke zu Radiusbereichen zuordnen, importieren oder neu berechnen. Eine PLZ kann mehrere Bezirke haben. Manuelle Änderungen erhalten den vorhandenen Preis.</p><div className="admin-row-actions"><button className="button" onClick={()=>edit('RADIUS_ADD')}>Neue Radiuszuordnung</button><button className="button" onClick={()=>setImportMode('CALCULATE')}>Radien neu berechnen</button><button className="button" onClick={()=>setImportMode('RADIUSZONE')}>Radius-CSV importieren</button></div></>}
|
||||
{data&&tab!=='times'&&<><div className="admin-toolbar"><Field label={tab==='zones'?'PLZ oder Zone suchen':'PLZ oder Bezirk suchen'}><input type="search" value={query} onChange={e=>{setQuery(e.target.value);setPage(0);}}/></Field><span>{count} Zuordnungen</span></div><div className="table-wrap"><table><thead>{tab==='zones'?<tr><th>PLZ / Land</th><th>Zone</th><th>Express</th><th>Zonenaufschlag</th><th>Zusatzwert 3</th><th>{uiText("Aktion")}</th></tr>:<tr><th>{uiText("PLZ")}</th><th>Bezirk / Stadt</th><th>Radiusbereich</th><th>{uiText("Preis")}</th><th>Aktionen</th></tr>}</thead><tbody>{tab==='zones'?filteredMappings.slice(page*100,page*100+100).map(m=><tr key={`${m.zoneId}-${m.postalId}`}><td>{m.postalCode||`#${m.postalId}`} / {m.country}</td><td>{data.zones.find(z=>z.id===m.zoneId)?.name}</td><td>{money(m.express)}</td><td>{money(m.surcharge)}</td><td>{money(m.extra)}</td><td><button className="button" onClick={()=>edit('MAPPING_REMOVE',{id:m.postalId,zoneId:m.zoneId})}>Entfernen</button></td></tr>):filteredRadii.slice(page*100,page*100+100).map(r=><tr key={r.id}><td>{r.postalCode}</td><td>{r.district}</td><td>{r.number}</td><td>{money(r.price)} €</td><td><div className="admin-row-actions"><button className="button" onClick={()=>edit('RADIUS_UPDATE',{id:r.id,postalCode:r.postalCode,district:r.district,number:r.number})}>{uiText("Bearbeiten")}</button><button className="button" onClick={()=>edit('RADIUS_REMOVE',{id:r.id})}>Entfernen</button></div></td></tr>)}</tbody></table></div>{!count&&<p>Keine Zuordnungen gefunden.</p>}{count>100&&<div className="admin-row-actions"><button className="button" disabled={!page} onClick={()=>setPage(p=>p-1)}>Zurück</button><span>Seite {page+1} {uiText(" von ")}{Math.ceil(count/100)}</span><button className="button" disabled={(page+1)*100>=count} onClick={()=>setPage(p=>p+1)}>Weiter</button></div>}</>}
|
||||
{data&&tab==='times'&&<><p>Die festen Nummern bleiben bei Änderungen erhalten. Auslieferarten 0 bis 3 sind mit den vier Zonenpreisen verbunden. Versandzeiten konfigurieren die bestehenden Versandläufe.</p>{Object.entries(labels).map(([type,label])=>{
|
||||
const entries=data.entries.filter(e=>e.type===type),defaults=data.defaults.filter(e=>e.type===type),clock=['sms_time','cartage_time'].includes(type),allowed=type==='day_time'?data.canManageDayTimes:type==='discount_mode'?data.canManageDiscounts:true;
|
||||
return <section className="business-section" key={type}><h3>{label}</h3>{type==='cartage_time'&&<p className="alert info">Die aktuelle PHP-Versandroutine liest diese Uhrzeit, verwendet sie aber nicht als Versandsperre. Eine Änderung der Uhrzeit aktiviert oder stoppt den Versanddienst nicht.</p>}{clock?<p>{entries[0]?.mapped||defaults[0]?.mapped||'Nicht hinterlegt'}{!entries.length&&defaults.length>0?' · Allgemeiner Standard':''}</p>:<div className="table-wrap"><table><thead><tr><th>{uiText("Nr.")}</th><th>{uiText("Bezeichnung")}</th><th>{type==='discount_mode'?'Rabatt-Typ':'Zeitvorgabe'}</th><th>{uiText("Aktion")}</th></tr></thead><tbody>{entries.map(e=><tr key={e.id}><td>{e.sort}</td><td>{e.name}</td><td>{type==='discount_mode'?(defaults.find(d=>String(d.sort)===e.mapped)?.name||e.mapped||'Standardzuordnung'):e.mapped||'Keine Zeitvorgabe'}</td><td>{allowed&&<button className="button" onClick={()=>editEntry(e)}>{uiText("Bearbeiten")}</button>}</td></tr>)}</tbody></table></div>}{!clock&&!entries.length&&<p>Keine kundenspezifischen Einträge.</p>}{allowed&&<div className="admin-row-actions"><button className="button" disabled={type==='delivery_time'&&entries.some(e=>e.sort>=3)} onClick={()=>edit(clock?'TIME_SET':'META_ADD',{type,from:clock?(entries[0]?.mapped||defaults[0]?.mapped||''):'',mapped:type==='discount_mode'?String(defaults[0]?.sort??''):''})}>{clock?'Zeit ändern':'Eintrag ergänzen'}</button><button className="button" disabled={!defaults.length} onClick={()=>edit('META_DEFAULTS',{type})}>Standardwerte übernehmen</button>{!clock&&entries.length>0&&<button className="button" onClick={()=>edit('META_REMOVE_LAST',{type,id:entries[entries.length-1].id})}>Letzten Eintrag entfernen</button>}</div>}{!allowed&&<p className="form-hint">Für Änderungen fehlt die erforderliche Berechtigung.</p>}</section>;
|
||||
})}</>}
|
||||
</div></Modal>
|
||||
{draft&&<Modal title={`${titles[draft.action]}${draft.type?' · '+labels[draft.type]:''}`} onClose={()=>void closeEditor()}><form onSubmit={e=>{e.preventDefault();void prepare();}}><div className="modal-body"><ErrorMessage message={error}/><fieldset className="business-fieldset" disabled={busy||uncertain}><div className="form-grid cols-2">
|
||||
{['ZONE_ADD','ZONE_RENAME'].includes(draft.action)&&<Field label="Zonenbezeichnung"><input required maxLength={50} value={draft.name} onChange={e=>change({name:e.target.value})}/></Field>}
|
||||
{['MAPPING_ADD','RADIUS_ADD','RADIUS_UPDATE'].includes(draft.action)&&<Field label="Postleitzahl"><input required maxLength={10} value={draft.postalCode} onChange={e=>change({postalCode:e.target.value})}/></Field>}
|
||||
{draft.action==='MAPPING_ADD'&&<><Field label="Land (Länderkürzel)"><input required maxLength={10} value={draft.country} onChange={e=>change({country:e.target.value.toUpperCase()})}/></Field><Field label="Servicezone"><select value={draft.zoneId} onChange={e=>change({zoneId:Number(e.target.value)})}>{data?.zones.map(z=><option key={z.id} value={z.id}>{z.number} · {z.name}</option>)}</select></Field></>}
|
||||
{['RADIUS_ADD','RADIUS_UPDATE'].includes(draft.action)&&<><Field label="Bezirk / Stadt"><input required maxLength={50} value={draft.district} onChange={e=>change({district:e.target.value})}/></Field><Field label="Radiusbereich"><input required type="number" min={0} max={127} step={1} value={draft.number} onChange={e=>change({number:Number(e.target.value)})}/></Field></>}
|
||||
{['META_ADD','META_UPDATE'].includes(draft.action)&&<Field label={uiText("Bezeichnung")}><input required maxLength={100} value={draft.name} onChange={e=>change({name:e.target.value})}/></Field>}
|
||||
{!deletion&&draft.type==='day_time'&&<><Field label="Von"><input required type="time" value={draft.from} onChange={e=>change({from:e.target.value})}/></Field><Field label="Bis"><input required type="time" value={draft.to} onChange={e=>change({to:e.target.value})}/></Field></>}
|
||||
{!deletion&&draft.type==='delivery_time'&&<Field label="Maximale Bestelluhrzeit"><input required type="time" value={draft.to} onChange={e=>change({to:e.target.value})}/></Field>}
|
||||
{!deletion&&draft.type==='discount_mode'&&<Field label="Rabatt-Typ"><select required value={draft.mapped} onChange={e=>change({mapped:e.target.value})}><option value="">Bitte wählen</option>{data?.defaults.filter(e=>e.type==='discount_mode').map(e=><option value={e.sort} key={e.id}>{e.name}</option>)}</select></Field>}
|
||||
{draft.action==='TIME_SET'&&<Field label="Uhrzeit"><input required type="time" value={draft.from} onChange={e=>change({from:e.target.value})}/></Field>}
|
||||
</div></fieldset>{deletion&&!preview&&<p>Die Vorschau zeigt, welche bestehenden Einträge betroffen sind.</p>}{preview&&<div className="customer-settings-preview" role="status"><h3>Änderungsvorschau</h3><p>{preview.description}</p></div>}{uncertain&&<p>Speicherausgang unklar. Bitte denselben Vorgang erneut prüfen.</p>}</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void closeEditor()}>Abbrechen</button>{!preview?<button className="button primary" disabled={busy}>Änderung prüfen</button>:<button className="button primary" type="button" disabled={busy} onClick={()=>void save()}>{uncertain?'Speichervorgang erneut prüfen':uiText("Änderung speichern")}</button>}</div></form></Modal>}
|
||||
{importMode&&<CustomerServiceImports id={id} mode={importMode} onClose={()=>setImportMode(null)} onSaved={()=>{setImportMode(null);setNotice('Zuordnungen gespeichert.');setBusy(true);void load().catch(e=>setError(e.message)).finally(()=>setBusy(false));}}/>}
|
||||
{discardDialog}</>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowDown,ArrowUp,Save} from 'lucide-react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
@@ -7,6 +8,8 @@ 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 uiText=useUiText();
|
||||
|
||||
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('');
|
||||
@@ -20,5 +23,5 @@ export function CustomerServices({id,onClose}:{id:number;onClose:()=>void}) {
|
||||
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}</>;
|
||||
{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}>{uiText("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()}>{uiText("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}</>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
import {MasterDataEditor} from './BusinessForms';
|
||||
type Hit={id:number;number:string;company:string;addition:string;headquartersId:number;headquarters:string;score:number;archived:boolean;hidden:boolean;canOpen:boolean};
|
||||
type Result={phrase:string;words:string[];items:Hit[];offset:number;hasMore:boolean;headquarters:string[]};
|
||||
export function CustomerSimilarity({initial,onClose,onSaved}:{initial:string;onClose:()=>void;onSaved:()=>void}){
|
||||
const [phrase,setPhrase]=useState(initial),[result,setResult]=useState<Result|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[customer,setCustomer]=useState<number|null>(null);const controller=useRef<AbortController|null>(null);
|
||||
async function search(value:string,offset=0){controller.current?.abort();const c=new AbortController();controller.current=c;setBusy(true);setError('');setResult(null);try{const r=await request<Result>(`/master-data/customers/similar?phrase=${encodeURIComponent(value)}&offset=${offset}`,{signal:c.signal});if(!c.signal.aborted)setResult(r);}catch(e){if(!c.signal.aborted)setError((e as Error).message);}finally{if(!c.signal.aborted)setBusy(false);}}
|
||||
useEffect(()=>{if(initial.trim())void search(initial);return()=>controller.current?.abort();},[initial]);
|
||||
function submit(e:FormEvent){e.preventDefault();void search(phrase);}
|
||||
return <><Modal title="Ähnliche Kundenfirmen suchen" wide onClose={()=>{if(customer===null)onClose();}}><div className="modal-body customer-similarity"><form onSubmit={submit}><div className="form-grid cols-2"><Field label="Firmenname"><input value={phrase} maxLength={50} onChange={e=>setPhrase(e.target.value)}/></Field><button className="button" disabled={!phrase.trim()}>Suchen</button></div></form>
|
||||
<p className="form-hint">Bis zu drei Namensbestandteile, auch mit wiederholten oder einzelnen abweichenden Buchstaben. Treffer öffnen den vorhandenen Kunden; Ihre bisherigen Eingaben bleiben erhalten.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Ähnliche Firmen werden gesucht …</p>}{result&&<><p>Suche nach „{result.phrase}“ · Niederlassungen: {result.headquarters.join(', ')}</p>{result.items.length?<div className="table-scroll"><table className="data-table"><thead><tr><th>Kundennummer</th><th>Firma</th><th>Niederlassung</th><th>Status</th></tr></thead><tbody>{result.items.map(r=><tr key={r.id}><td>{r.canOpen?<button className="button" onClick={()=>setCustomer(r.id)}>{r.number||`Kunde #${r.id}`}</button>:r.number}</td><td>{r.company}<br/>{r.addition}</td><td>{r.headquarters}</td><td>{[r.archived?'Archiviert':'',r.hidden?'Ausgeblendet':''].filter(Boolean).join(' · ')||'Aktiv'}{!r.canOpen&&<div>Zum Bearbeiten zuerst die Arbeitsniederlassung wechseln.</div>}</td></tr>)}</tbody></table></div>:<p role="status">Keine ähnlichen Kunden gefunden.</p>}<div className="modal-footer"><button className="button" disabled={busy||result.offset===0} onClick={()=>void search(result.phrase,result.offset-100)}>Vorherige Treffer</button><span>Seite {result.offset/100+1}</span><button className="button" disabled={busy||!result.hasMore} onClick={()=>void search(result.phrase,result.offset+100)}>Weitere Treffer</button></div></>}
|
||||
</div><div className="modal-footer"><button className="button" onClick={()=>{if(customer===null)onClose();}}>Schließen</button></div></Modal>{customer!==null&&<MasterDataEditor kind="customers" id={customer} onSaved={onSaved} onClose={()=>setCustomer(null)}/>}</>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Modal} from './FormElements';
|
||||
import {request,downloadBusinessFile} from './api';
|
||||
type Preview={revision:string;label:string;page:number;pages:number;png:string};
|
||||
export function CustomerStammblatt({customer,onClose}:{customer:number;onClose:()=>void}){
|
||||
const base=`/master/customers/${customer}/stammblatt`,[page,setPage]=useState(0),[reload,setReload]=useState(0),[data,setData]=useState<Preview|null>(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false);const revision=useRef<string|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setData(null);setError('');setLoading(true);request<Preview>(base+'/preview',{method:'POST',body:JSON.stringify({page,revision:revision.current}),signal:c.signal}).then(d=>{if(!c.signal.aborted){revision.current=d.revision;setData(d);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[base,page,reload]);
|
||||
async function download(){if(!data||busy||loading)return;setBusy(true);setError('');try{await downloadBusinessFile(base+'/document.pdf?'+new URLSearchParams({revision:data.revision}),undefined,`Kunde-${customer}.pdf`,'GET');}catch(e){setError((e as Error).message);setData(null);}finally{setBusy(false);}}
|
||||
function reset(){revision.current=null;setPage(0);setReload(n=>n+1);}
|
||||
return <Modal title="Kundenstammblatt als PDF" wide onClose={()=>{if(!busy)onClose();}}><div className="modal-body"><p>Gespeicherte Stammdaten einschließlich Abrechnungsvorgaben und interner Bemerkungen.</p>{error&&<p className="alert error" role="alert">{error}</p>}{loading&&<p>PDF-Vorschau wird erstellt …</p>}{data&&<><h3>{data.label}</h3><p>Seite {data.page+1} / {data.pages}</p><img alt={`Kundenstammblatt Seite ${data.page+1}`} src={'data:image/png;base64,'+data.png} style={{display:'block',width:'100%',height:'auto'}}/></>}</div><div className="modal-footer"><button className="button" disabled={busy} onClick={onClose}>Schließen</button><button className="button" disabled={busy||loading} onClick={reset}>Vorschau neu laden</button><button className="button" disabled={busy||loading||!data||page===0} onClick={()=>setPage(p=>p-1)}>Vorherige Seite</button><button className="button" disabled={busy||loading||!data||page+1>=data.pages} onClick={()=>setPage(p=>p+1)}>Nächste Seite</button><button className="button primary" disabled={busy||loading||!data} onClick={()=>void download()}>PDF herunterladen</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
import {CustomerOrder} from './CustomerOrder';
|
||||
type Query={from:string;to:string;center:number;descendants:boolean;page:number};
|
||||
type Options={name:string;center:number;centers:{id:number;parent:number;label:string}[];from:string;to:string;descendants:boolean;amountLabel:string};
|
||||
type Row={pricePending?:boolean;paymentAllocationPending?:boolean;id:number;center:number;payer:string;orderedBy:string;ordered:string;finished:string;booked:string;courier:string;vehicle:string;incomplete:boolean;exported:boolean;split:boolean;lines:{label:string;amount:number|null}[];amount:number|null};
|
||||
type Report={pendingJobs?:number;allocationPendingJobs?:number;rows:Row[];page:number;hasNext:boolean;totalRows:number;totalJobs:number;total:number;warnings:string[];amountLabel:string};
|
||||
const money=(n:number|null)=>n==null?"–":n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
const date=(s:string)=>s?new Date(s).toLocaleString('de-DE'):'–';
|
||||
export function CustomerStatement({onClose}:{onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query|null>(null),[applied,setApplied]=useState<Query|null>(null),[data,setData]=useState<Report|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[detail,setDetail]=useState<{job:number;query:Query}|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);request<Options>('/customer-portal/statement',{signal:c.signal}).then(o=>{if(c.signal.aborted)return;setOptions(o);const q={from:o.from,to:o.to,center:o.center,descendants:o.descendants,page:0};setQuery(q);setApplied(q);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[]);
|
||||
useEffect(()=>{if(!applied)return;const c=new AbortController();setBusy(true);setError('');request<Report>('/customer-portal/statement',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(r=>{if(!c.signal.aborted)setData(r);}).catch(e=>{if(!c.signal.aborted){setError(e.message);setData(null);}}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied]);
|
||||
if(detail)return <CustomerOrder job={detail.job} endpoint="/customer-portal/statement/detail" payload={detail} onClose={()=>setDetail(null)}/>;
|
||||
return <Modal title="Abrechnungsübersicht" wide onClose={onClose}><div className="modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{options&&query&&<><h3>{options.name}</h3><form onSubmit={e=>{e.preventDefault();setApplied({...query,page:0});}}><fieldset disabled={busy}><div className="admin-toolbar">
|
||||
<Field label={uiText("Kostenstelle")}><select value={query.center} onChange={e=>setQuery({...query,center:Number(e.target.value)})}>{options.centers.map(c=><option value={c.id} key={c.id}>{c.label}</option>)}</select></Field>
|
||||
<Field label="Kostenstellenumfang"><select value={query.descendants?'all':'one'} onChange={e=>setQuery({...query,descendants:e.target.value==='all'})}><option value="one">Nur aktuelle Kostenstelle</option><option value="all">Alle zugeordneten Kostenstellen</option></select></Field>
|
||||
<Field label="Erledigt von"><input type="date" required value={query.from} onChange={e=>setQuery({...query,from:e.target.value})}/></Field><Field label="Erledigt bis"><input type="date" required value={query.to} onChange={e=>setQuery({...query,to:e.target.value})}/></Field><button className="button primary" accessKey="a">{uiText("Anzeigen")}</button>
|
||||
</div></fieldset></form><p>Erledigte Aufträge im aktuellen Bestand. Ungeklärte Preise und offene Zahlungszuordnungen werden gesondert ausgewiesen und nicht zur Summe addiert. Bei Kostensplit werden die zugeordneten Kostenstellenanteile angezeigt.</p></>}
|
||||
{busy&&<p>Abrechnungsübersicht wird geladen …</p>}{data&&<><div className="admin-toolbar"><strong>{data.totalJobs} Aufträge · {data.totalRows} Kostenstellenpositionen · {data.pendingJobs||data.allocationPendingJobs?"Summe geklärter Beträge":"Gesamtsumme"} {money(data.total)}{data.pendingJobs?` · ${data.pendingJobs} ${data.pendingJobs===1?"Auftrag":"Aufträge"} in Preisprüfung`:""}{data.allocationPendingJobs?` · ${data.allocationPendingJobs} ${data.allocationPendingJobs===1?"Auftrag":"Aufträge"} mit offener Zahlungszuordnung`:""}</strong><span>{data.amountLabel}</span><button className="button" disabled={busy||data.page===0} onClick={()=>setApplied(a=>a?{...a,page:a.page-1}:a)}>Zurück</button><span>Seite {data.page+1}</span><button className="button" disabled={busy||!data.hasNext} onClick={()=>setApplied(a=>a?{...a,page:a.page+1}:a)}>Weiter</button></div>
|
||||
{data.warnings.map((w,i)=><p className="alert warning" key={i}>{w}</p>)}<div className="table-scroll"><table className="data-table"><thead><tr><th>{uiText("Auftrag")}</th><th>Zahler / Besteller</th><th>Bestellt / Erledigt / Gebucht</th><th>Kurier / Fahrzeug</th><th>{uiText("Leistung")}</th><th>{data.amountLabel}</th></tr></thead><tbody>{data.rows.map(r=><tr key={`${r.id}:${r.center}`}><td><button className="button" disabled={busy||!applied} onClick={()=>{if(applied)setDetail({job:r.id,query:{...applied,center:r.center,descendants:false,page:0}});}}>Auftrag #{r.id} ansehen</button>{r.incomplete&&<p>Unvollständig</p>}{r.exported&&<p>Exportiert</p>}</td><td><strong>{r.payer}</strong><p>{r.orderedBy}</p>{r.split&&<p>Kostenstellenanteil</p>}</td><td><p>Bestellt: {date(r.ordered)}</p><p>Erledigt: {date(r.finished)}</p><p>Gebucht: {date(r.booked)}</p></td><td>{r.courier}<p>{r.vehicle}</p></td><td>{r.lines.map((l,i)=><p key={i}>{l.label}: {r.pricePending?'Preis in Prüfung':r.paymentAllocationPending?'Zahlungszuordnung offen':money(l.amount)}</p>)}</td><td><strong>{r.pricePending?'Preis in Prüfung':r.paymentAllocationPending?'Zahlungszuordnung offen':money(r.amount)}</strong></td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Keine abrechnungsfähigen Aufträge in dieser Auswahl.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
type Query={from:string;to:string;monthly:boolean;timeSplit:number;split:string;splitCenter:boolean;splitHeadquarters:boolean;groupCustomers:boolean;headquarters:number[];vehicle:string;timeFrom:string|null;timeTo:string|null;comparisons:number;sort:string;descending:boolean};
|
||||
type Options={from:string;to:string;headquarters:{id:number;label:string}[];currentHeadquarters:number;meta:boolean;canDownload:boolean;maxComparisons:number};
|
||||
type Column={key:string;label:string;type:string};
|
||||
type Period={from:string;to:string;orders:number;amount:number;rows:Record<string,string|number>[]};
|
||||
type Report={columns:Column[];periods:Period[];canDownload:boolean;excludedCancellations:boolean;relatedCustomer:boolean};
|
||||
const money=(n:number)=>n.toLocaleString('de-DE',{style:'currency',currency:'EUR'});
|
||||
export function CustomerStatistics({onClose}:{onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query|null>(null),[applied,setApplied]=useState<Query|null>(null),[data,setData]=useState<Report|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();request<Options>('/customer-portal/statistics',{signal:c.signal}).then(o=>{if(c.signal.aborted)return;setOptions(o);setQuery({from:o.from,to:o.to,monthly:false,timeSplit:0,split:'NONE',splitCenter:false,splitHeadquarters:false,groupCustomers:false,headquarters:[o.currentHeadquarters],vehicle:'',timeFrom:null,timeTo:null,comparisons:0,sort:'number',descending:false});}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[]);
|
||||
useEffect(()=>{if(!applied)return;const c=new AbortController();setBusy(true);setError('');setData(null);request<Report>('/customer-portal/statistics/query',{method:'POST',body:JSON.stringify(applied),signal:c.signal}).then(r=>{if(!c.signal.aborted)setData(r);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[applied]);
|
||||
async function download(){if(!applied)return;setBusy(true);setError('');try{const file=await request<{filename:string;content:string}>('/customer-portal/statistics/download',{method:'POST',body:JSON.stringify(applied)});const url=URL.createObjectURL(new Blob([file.content],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download=file.filename;a.click();URL.revokeObjectURL(url);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
function sort(c:Column){if(applied){const next={...applied,sort:c.key,descending:applied.sort===c.key?!applied.descending:false};setApplied(next);setQuery(q=>q?{...q,sort:next.sort,descending:next.descending}:q);}}
|
||||
return <Modal title="Kundenstatistik" wide onClose={onClose}><div className="modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{options&&query&&<form onSubmit={e=>{e.preventDefault();setApplied({...query,sort:'number'});}}><fieldset disabled={busy}>
|
||||
<div className="admin-toolbar"><Field label="Statistik-Format"><select value={query.monthly?'month':'total'} onChange={e=>setQuery({...query,monthly:e.target.value==='month'})}><option value="total">Anzahl und Nettoumsatz pro Kunde</option><option value="month">Anzahl und Nettoumsatz pro Monat und Kunde</option></select></Field><Field label="Erledigt von"><input type="date" required value={query.from} onChange={e=>setQuery({...query,from:e.target.value})}/></Field><Field label="Erledigt bis"><input type="date" required value={query.to} onChange={e=>setQuery({...query,to:e.target.value})}/></Field></div>
|
||||
{options.meta&&<fieldset><legend>Niederlassungen</legend><div className="admin-toolbar"><button type="button" className="button" onClick={()=>setQuery({...query,headquarters:options.headquarters.map(h=>h.id)})}>{uiText("Alle")}</button><button type="button" className="button" onClick={()=>setQuery({...query,headquarters:[]})}>{uiText("Keine")}</button><button type="button" className="button" onClick={()=>setQuery({...query,headquarters:[options.currentHeadquarters]})}>Eigene</button>{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>)}</div></fieldset>}
|
||||
<div className="admin-toolbar"><label className="admin-check"><input type="checkbox" checked={query.timeFrom!==null} onChange={e=>setQuery({...query,timeFrom:e.target.checked?'00:00':null,timeTo:e.target.checked?'23:59':null})}/>Tägliches Zeitintervall begrenzen</label>{query.timeFrom!==null&&<><Field label="Uhrzeit von"><input type="time" required value={query.timeFrom} onChange={e=>setQuery({...query,timeFrom:e.target.value})}/></Field><Field label="Uhrzeit bis"><input type="time" required value={query.timeTo??''} onChange={e=>setQuery({...query,timeTo:e.target.value})}/></Field></>}</div>
|
||||
<div className="admin-toolbar"><Field label="Zeitliche Aufteilung"><select value={query.timeSplit} onChange={e=>setQuery({...query,timeSplit:Number(e.target.value)})}><option value={0}>{uiText("Keine")}</option><option value={30}>Halbstündlich</option><option value={60}>Stündlich</option><option value={120}>Zweistündlich</option></select></Field><Field label="Weitere Aufteilung"><select value={query.split} onChange={e=>setQuery({...query,split:e.target.value})}><option value="NONE">{uiText("Keine")}</option><option value="VEHICLE">Fahrzeugtyp</option><option value="START_POSTAL">{uiText("Start-PLZ")}</option><option value="END_POSTAL">{uiText("Ziel-PLZ")}</option><option value="VEHICLE_START_POSTAL">Fahrzeugtyp und Start-PLZ</option><option value="VEHICLE_END_POSTAL">Fahrzeugtyp und Ziel-PLZ</option></select></Field><Field label="Fahrzeug (SID)"><input value={query.vehicle} maxLength={40} onChange={e=>setQuery({...query,vehicle:e.target.value})}/></Field></div>
|
||||
<div className="admin-toolbar"><label className="admin-check"><input type="checkbox" checked={query.splitCenter} onChange={e=>setQuery({...query,splitCenter:e.target.checked})}/>Nach bezahlender Kostenstelle aufteilen</label>{options.meta&&<><label className="admin-check"><input type="checkbox" checked={query.splitHeadquarters} onChange={e=>setQuery({...query,splitHeadquarters:e.target.checked})}/>Nach Niederlassung aufteilen</label><Field label="Kundenauswahl"><select value={query.groupCustomers?'group':'own'} onChange={e=>setQuery({...query,groupCustomers:e.target.value==='group'})}><option value="own">Eigener Kunde ohne Gruppenkunden</option><option value="group">Nur zugeordnete Gruppenkunden</option></select></Field></>}{options.maxComparisons>0&&<Field label="Vergleichsjahre"><select value={query.comparisons} onChange={e=>setQuery({...query,comparisons:Number(e.target.value)})}>{Array.from({length:options.maxComparisons+1},(_,i)=><option value={i} key={i}>{i===0?'Ohne Vergleich':i}</option>)}</select></Field>}<Field label="Sortierung"><select value={query.descending?'desc':'asc'} onChange={e=>setQuery({...query,descending:e.target.value==='desc'})}><option value="asc">Aufsteigend</option><option value="desc">Absteigend</option></select></Field><button className="button primary">Berechnen</button></div>
|
||||
</fieldset></form>}{!options&&!error&&<p>Statistikoptionen werden geladen …</p>}{busy&&<p>Statistik wird berechnet …</p>}
|
||||
{data&&<><p>Erledigte Aufträge nach Erledigungsdatum · Nettobeträge · {data.relatedCustomer?'Zuordnung zum zugehörigen Kunden':'Zuordnung zur bezahlenden Kostenstelle'}{data.excludedCancellations?' · Kostenfreie Stornos ausgeschlossen':''}</p>{data.canDownload&&<button className="button" disabled={busy} onClick={()=>void download()}>Statistik als CSV herunterladen</button>}{data.periods.map((p,i)=><section key={i}><h3>{p.from} {uiText(" bis ")}{p.to}{i>0?' · Vergleichsjahr':''}</h3><p><strong>{p.orders} Aufträge · {money(p.amount)}</strong></p><div className="table-scroll"><table className="data-table"><thead><tr>{data.columns.map(c=><th key={c.key}><button className="button" disabled={busy} onClick={()=>sort(c)}>{c.label}{applied?.sort===c.key?(applied.descending?' ↓':' ↑'):''}</button></th>)}</tr></thead><tbody>{p.rows.map((r,j)=><tr key={j}>{data.columns.map(c=><td key={c.key} style={{textAlign:c.type==='text'?'left':'right'}}>{c.type==='money'?money(Number(r[c.key])):r[c.key]}</td>)}</tr>)}</tbody></table></div>{!p.rows.length&&<p>Keine erledigten Aufträge für diese Auswahl.</p>}</section>)}</>}
|
||||
</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {CustomerExports} from './CustomerExports';
|
||||
import {AllergoImport} from './AllergoImport';
|
||||
import {TourCsvImport,type TourImportFormat} from './TourCsvImport';
|
||||
import {ZoneJobImport,type ZoneImportFormat} from './ZoneJobImport';
|
||||
import {PostalNeighbourImport} from './PostalNeighbourImport';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {request} from './api';
|
||||
import {Field} from './FormElements';
|
||||
import {MasterDocumentsDialog} from './MasterDocuments';
|
||||
type Kind={id:string;label:string};type Entry={id:number;label:string};type Options={canExport?:boolean;canDocuments?:boolean;headquarters:number;canUpload:boolean;canDownload:boolean;kinds:Kind[]};
|
||||
export function DataTransfer({onDirtyChange}:{onDirtyChange:(dirty:boolean)=>void}){
|
||||
const [masterExport,setMasterExport]=useState(false);
|
||||
const [allergoCustomer,setAllergoCustomer]=useState<number|null>(null);
|
||||
const [tourCustomer,setTourCustomer]=useState<{id:number;format:TourImportFormat}|null>(null);
|
||||
const [zoneJobCustomer,setZoneJobCustomer]=useState<{id:number;format:ZoneImportFormat}|null>(null);
|
||||
const [postalImport,setPostalImport]=useState(false);
|
||||
const [options,setOptions]=useState<Options|null>(null),[kind,setKind]=useState('general'),[search,setSearch]=useState(''),[items,setItems]=useState<Entry[]>([]),[truncated,setTruncated]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[opened,setOpened]=useState<{kind:string;id:number}|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();request<Options>('/data-transfer',{signal:c.signal}).then(setOptions).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[]);
|
||||
useEffect(()=>{onDirtyChange(masterExport||!!opened||postalImport||!!zoneJobCustomer||!!tourCustomer||!!allergoCustomer);return()=>onDirtyChange(false);},[masterExport,opened,postalImport,zoneJobCustomer,tourCustomer,allergoCustomer,onDirtyChange]);
|
||||
useEffect(()=>{const c=new AbortController();setItems([]);setTruncated(false);if(kind==='general')return;setBusy(true);setError('');request<{items:Entry[];truncated:boolean}>(`/data-transfer/objects?kind=${kind}&search=${encodeURIComponent(search)}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted){setItems(d.items);setTruncated(d.truncated);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[kind,search]);
|
||||
return <section className="panel"><h2>Datentransfer</h2>{options?.canExport&&<section className="business-section"><h3>Stammdaten und Abrechnung exportieren</h3><p>Debitoren, Kreditoren, Fahrzeuge sowie Aufträge und Gutschriften mit den gespeicherten Exportprofilen ausgeben.</p><button className="button primary" onClick={()=>setMasterExport(true)}>Export öffnen</button></section>}<p>Gemeinsame Dokumente und Unterverzeichnisse sowie Dateien zu Kunden, Transporteuren, Fahrzeugen, Aufträgen und Mitarbeitern.</p>{error&&<p className="alert error" role="alert">{error}</p>}{options&&options.canDocuments!==false&&<><div className="form-grid"><Field label="Dokumentbereich"><select value={kind} onChange={e=>{setKind(e.target.value);setSearch('');}}><option value="general">Allgemeine Dokumente</option>{options.kinds.map(k=><option key={k.id} value={k.id}>{k.label}</option>)}</select></Field>{kind!=='general'&&<Field label="Datensatz suchen"><input value={search} maxLength={100} placeholder={kind==='orders'?'Auftragsnummer oder Besteller':'Nummer oder Name'} onChange={e=>setSearch(e.target.value)}/></Field>}</div>
|
||||
{kind==='general'?<><p>Die allgemeine Ablage ist dem aktuellen Mandanten zugeordnet. Geschützte Objektverzeichnisse werden entsprechend Ihren Rechten angezeigt.</p><button className="button primary" onClick={()=>setOpened({kind:'general',id:options.headquarters})}>Allgemeine Dokumente öffnen</button></>:<>{busy&&<p>Datensätze werden gesucht …</p>}{truncated&&<p className="alert info">Die ersten 100 Treffer werden angezeigt. Bitte die Suche eingrenzen.</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Nummer</th><th>Datensatz</th><th>Dokumente</th></tr></thead><tbody>{items.map(i=><tr key={i.id}><td>{i.id}</td><td>{i.label}</td><td><button className="button" onClick={()=>setOpened({kind,id:i.id})}>Dokumente öffnen</button>{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setZoneJobCustomer({id:i.id,format:'ZONE_JOBS'})}>Zonenaufträge importieren</button>}{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setZoneJobCustomer({id:i.id,format:'BMW'})}>BMW importieren</button>}{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setTourCustomer({id:i.id,format:'TOURS'})}>Touren importieren</button>}{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setTourCustomer({id:i.id,format:'DHL'})}>DHL importieren</button>}{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setTourCustomer({id:i.id,format:'TIMS'})}>TIMS importieren</button>}{kind==='customers'&&options.canUpload&&<button className="button" onClick={()=>setAllergoCustomer(i.id)}>ALLERGO importieren</button>}</td></tr>)}</tbody></table></div>{!busy&&!items.length&&<p>Keine passenden Datensätze in dieser Niederlassung.</p>}</>}</>}
|
||||
{options?.canUpload&&<section className="business-section"><h3>Datenimporte</h3><button className="button" onClick={()=>setPostalImport(true)}>PLZ-Nachbarschaften importieren</button></section>}
|
||||
{masterExport&&<CustomerExports central onClose={()=>setMasterExport(false)}/>}
|
||||
{allergoCustomer&&<AllergoImport id={allergoCustomer} onClose={()=>setAllergoCustomer(null)}/>}
|
||||
{tourCustomer&&<TourCsvImport {...tourCustomer} onClose={()=>setTourCustomer(null)}/>}
|
||||
{zoneJobCustomer&&<ZoneJobImport {...zoneJobCustomer} onClose={()=>setZoneJobCustomer(null)}/>}
|
||||
{postalImport&&<PostalNeighbourImport onClose={()=>setPostalImport(false)}/>}
|
||||
{opened&&<MasterDocumentsDialog {...opened} onClose={()=>setOpened(null)}/>}</section>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field} from './FormElements';
|
||||
|
||||
export function DispositionDayPicker({value,today,confirmDate,disabled,onChange}:{value:string;today?:string;confirmDate?:boolean;disabled:boolean;onChange:(day:string)=>void}){
|
||||
const [draft,setDraft]=useState(value);
|
||||
useEffect(()=>{setDraft(value);},[value,confirmDate]);
|
||||
const pending=!!confirmDate&&draft!==value;
|
||||
const valid=/^\d{4}-\d{2}-\d{2}$/.test(draft)&&draft>='2000-01-01'&&draft<='2100-12-31';
|
||||
function choose(day:string){setDraft(day);if(!confirmDate&&day)onChange(day);}
|
||||
return <><Field label={confirmDate?'Gewünschter Dispositionstag':'Dispositionstag'}><input type="date" min="2000-01-01" max="2100-12-31" disabled={disabled} value={confirmDate?draft:value} onChange={e=>choose(e.target.value)}/></Field>
|
||||
{confirmDate&&<><button className="button" disabled={disabled||!pending||!valid} onClick={()=>onChange(draft)}>Datum übernehmen</button><span className="form-hint" role="status">Angezeigter Tag: {value?value.split('-').reverse().join('.'): '–'}{pending?' · Neues Datum noch nicht übernommen.':''}</span></>}
|
||||
<button className="button" disabled={disabled||!today} onClick={()=>{if(today){setDraft(today);onChange(today);}}}>Heute</button></>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {useEffect,useState,type CSSProperties} from 'react';
|
||||
import {request} from './api';
|
||||
import {Modal} from './FormElements';
|
||||
export type DispositionCard={id:number;lines:string[];details:string[];zipcode:string;verticalId:boolean;colour:string;category:string};
|
||||
export function useDispositionCards(path:string|null,board:unknown):{cards:Record<number,DispositionCard>;error:string;denied:boolean;hideHours:boolean}{
|
||||
const [result,setResult]=useState<{path:string;board:unknown;cards:Record<number,DispositionCard>;error:string;denied:boolean;hideHours:boolean}|null>(null);
|
||||
useEffect(()=>{const c=new AbortController();if(!path)return;request<{cards:DispositionCard[];hideHours?:boolean}>(path,{signal:c.signal}).then(v=>{if(!c.signal.aborted)setResult({path,board,cards:Object.fromEntries(v.cards.map(card=>[card.id,card])),error:'',denied:false,hideHours:v.hideHours===true});}).catch(e=>{if(!c.signal.aborted)setResult({path,board,cards:{},error:e.message,denied:e.status===403,hideHours:false});});return()=>c.abort();},[path,board]);
|
||||
return result&&result.board===board&&result.path===path?result:{cards:{},error:'',denied:false,hideHours:false};
|
||||
}
|
||||
export function cardStyle(card?:DispositionCard):CSSProperties{
|
||||
if(!card)return {};let hex=card.colour.slice(1);if(hex.length===3)hex=hex.split('').map(c=>c+c).join('');
|
||||
const values=[0,2,4].map(i=>{const v=parseInt(hex.slice(i,i+2),16)/255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4;});
|
||||
const luminance=values[0]*.2126+values[1]*.7152+values[2]*.0722;
|
||||
return {backgroundColor:card.colour,color:(luminance+.05)/.05>=1.05/(luminance+.05)?'#000':'#fff'};
|
||||
}
|
||||
export function cardDescription(card?:DispositionCard){return card?[card.category,...card.lines,...card.details].join('\n'):'';}
|
||||
export function DispositionCardText({job,card}:{job:number;card?:DispositionCard}){return <>{card?.verticalId&&<span className="fds-card-id-vertical" aria-hidden="true">{job}</span>}{card?.zipcode&&<span className="fds-card-zip" aria-label={`Postleitzahl ${card.zipcode}`}>{card.zipcode}</span>}<span className="fds-card-body"><strong>#{job}{card&&` · ${card.category}`}</strong>{card?.lines.map((line,i)=><span className="fds-card-line" key={i}>{line||'\u00a0'}</span>)}</span></>;}
|
||||
export function DispositionCardDetails({card,onClose}:{card:DispositionCard;onClose:()=>void}){return <Modal title={`Auftrag #${card.id} · ${card.category}`} onClose={onClose}><div className="modal-body"><div className="fds-card-detail">{card.details.map((line,i)=><p key={i}>{line}</p>)}</div><hr/>{card.lines.filter(line=>line&&!card.details.includes(line)&&!card.details.includes("Zusage: "+line)).map((line,i)=><p key={i}>{line}</p>)}</div><div className="modal-footer"><button className="button" onClick={onClose}>Schließen</button></div></Modal>;}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {timelineWindow,timelineHourTicks,type DisplayRange,type TimelineWindow} from './dispositionTimelineRange.mjs';
|
||||
import {useUiText} from './UiLanguage';
|
||||
const hour=(h:number)=>`${String(h).padStart(2,'0')}:00`;
|
||||
export function TimelineRangeControls({range,fullDay,disabled,onChange}:{range?:DisplayRange;fullDay:boolean;disabled:boolean;onChange:(full:boolean)=>void}){
|
||||
const preset=timelineWindow(range),display=timelineWindow(range,fullDay),limited=preset.hours!==24;
|
||||
return <>{range?.warning&&<p className="alert info" role="status">{range.warning}</p>}{limited&&<div className="admin-toolbar"><span>Angezeigter Zeitraum: {hour(display.start)}–{hour(display.end)}</span><button type="button" className="button" disabled={disabled} onClick={()=>onChange(!fullDay)}>{fullDay?'Vorgegebenen Zeitraum anzeigen':'Gesamten Tag anzeigen'}</button><span className="form-hint">Außerhalb liegende Reservierungen bleiben in den Tagesaktionen erreichbar.</span></div>}</>;
|
||||
}
|
||||
export function TimelineHourScale({display,hidden}:{display:TimelineWindow;hidden:boolean}){
|
||||
const t=useUiText();return <div className="fds-timeline-scale" style={{paddingRight:0}}><span>{t('Fahrzeug')}</span><div style={{position:'relative',height:15}}>{timelineHourTicks(display).map(h=><span key={h} aria-hidden={hidden} style={{visibility:hidden?'hidden':'visible',position:'absolute',left:`${100*(h-display.start)/display.hours}%`,transform:h===display.start?'none':h===display.end?'translateX(-100%)':'translateX(-50%)'}}>{hour(h)}</span>)}</div></div>;
|
||||
}
|
||||
export function timelineTrackBackground(display:TimelineWindow){return `repeating-linear-gradient(to right,transparent 0,transparent calc(100% / ${display.hours} - 1px),#ded9e6 calc(100% / ${display.hours} - 1px),#ded9e6 calc(100% / ${display.hours}))`;}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {PersonalLinks} from './PersonalLinks';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,post,put,request} from './api';
|
||||
import {AccountTwoFactorDialog} from './AccountTwoFactor';
|
||||
|
||||
type Branch={id:number;label:string};
|
||||
type Profile={name:string;firstName:string;email:string;phone:string;mobile:string;account:string;birthdate:string};
|
||||
type Access={profile:Profile;revision:string;self:boolean;canPassword:boolean;canDelete:boolean;twoFactorActive:boolean;passwordChanged:string};
|
||||
const defaultBase='/administration/employees';
|
||||
const empty:Profile={name:'',firstName:'',email:'',phone:'',mobile:'',account:'',birthdate:''};
|
||||
export function EmployeeAccountDialog({employee,branches,headquarters,onClose,onDirtyChange,basePath=defaultBase,allowTwoFactor=true}:{basePath?:string;allowTwoFactor?:boolean;employee:number|null;branches:Branch[];headquarters:number|null;onClose:()=>void;onDirtyChange:(dirty:boolean)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=basePath,creating=employee===null;
|
||||
const [factor,setFactor]=useState(false);const [created,setCreated]=useState<number|null>(null);
|
||||
const [data,setData]=useState<Access|null>(null),[profile,setProfile]=useState<Profile>(empty),[hq,setHq]=useState(headquarters&&headquarters>0?headquarters:branches[0]?.id||0);
|
||||
const [password,setPassword]=useState(''),[confirmation,setConfirmation]=useState(''),[currentPassword,setCurrentPassword]=useState(''),[deleteAccount,setDeleteAccount]=useState(''),[mode,setMode]=useState<'password'|'delete'>('password');
|
||||
const [busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[done,setDone]=useState(false);
|
||||
const pending=useRef<{path:string;body:unknown;method:'POST'|'PUT'}|null>(null),sending=useRef(false);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!done&&(!!password||!!confirmation||!!currentPassword||!!deleteAccount||creating&&JSON.stringify(profile)!==JSON.stringify(empty));
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
useEffect(()=>{if(creating)return;const c=new AbortController();request<Access>(`${base}/${employee}/access`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[employee,creating]);
|
||||
function clear(){setPassword('');setConfirmation('');setCurrentPassword('');setDeleteAccount('');pending.current=null;}
|
||||
async function close(){if(sending.current||uncertain)return;if(!dirty||done||await discard('Ungespeicherte Zugangsdaten verwerfen?')){clear();onClose();}}
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Zugangsdaten verwerfen?'))return;sending.current=true;setBusy(true);setError('');try{clear();if(!creating)setData(await request<Access>(`${base}/${employee}/access`));setStale(false);setNotice('');}catch(e){setError((e as Error).message);}finally{setBusy(false);sending.current=false;}}
|
||||
async function changeMode(next:'password'|'delete'){if(sending.current||uncertain||next===mode)return;if(!dirty||await discard('Ungespeicherte Zugangsdaten verwerfen?')){clear();setMode(next);setError('');setNotice('');}}
|
||||
async function submit(e:FormEvent){e.preventDefault();if(sending.current||stale||done||!creating&&!data)return;
|
||||
if(!uncertain&&mode==='password'&&(password.length<8||password!==confirmation||!/[A-Za-z]/.test(password)||!/[0-9]/.test(password))){setError('Bitte ein übereinstimmendes Passwort mit mindestens acht Zeichen, einem Buchstaben und einer Ziffer eingeben.');return;}
|
||||
if(!pending.current){const requestId=crypto.randomUUID();pending.current=creating?{method:'POST',path:base,body:{requestId,headquarters:hq,...profile,birthdate:profile.birthdate||null,password,confirmation}}:mode==='delete'?{method:'POST',path:`${base}/${employee}/delete`,body:{requestId,revision:data!.revision,account:deleteAccount}}:{method:'PUT',path:`${base}/${employee}/password`,body:{requestId,revision:data!.revision,password,confirmation,currentPassword}};}
|
||||
sending.current=true;setBusy(true);setError('');try{const p=pending.current;const result=await (p.method==='POST'?post<{id:number}>:put<{id:number}>)(p.path,p.body);if(creating)setCreated(result.id);setUncertain(false);clear();setDone(true);setNotice(creating?'Mitarbeiterkonto angelegt. Rechte können jetzt über „Bearbeiten“ vergeben werden.':mode==='delete'?'Mitarbeiterkonto gelöscht. Bestehende NG-Sitzungen haben keinen Zugriff mehr.':data?.self?'Passwort geändert. Bitte mit dem neuen Passwort erneut anmelden.':'Passwort geändert. Bestehende NG-Sitzungen dieses Kontos werden beendet.');}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);if(!unknown)clear();}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
if(factor&&employee)return <AccountTwoFactorDialog employee={employee} onDirtyChange={onDirtyChange} onClose={()=>{setFactor(false);void reload();}}/>;
|
||||
const locked=busy||uncertain||stale||done;
|
||||
return <><Modal title={creating?uiText("Neuer Mitarbeiter"):`Zugang · ${data?.profile.firstName||''} ${data?.profile.name||''}`} onClose={()=>void close()}><form onSubmit={submit} className="employee-editor"><div className="admin-dialog-body modal-body">
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}
|
||||
{!creating&&!data&&!error&&<p>Zugangsdaten werden geladen …</p>}
|
||||
{creating?<fieldset disabled={locked}><legend>Mitarbeiterkonto</legend><div className="form-grid"><Field label="Niederlassung"><select value={hq} onChange={e=>setHq(Number(e.target.value))}>{branches.map(b=><option key={b.id} value={b.id}>{b.label}</option>)}</select></Field>{([['name','Name',50],['firstName','Vorname',50],['email','E-Mail',250],['phone','Telefon',20],['mobile','Mobil',20],['account','Anmeldename',20]] as const).map(([key,label,max])=><Field key={key} label={label}><input value={profile[key]} type={key==='email'?'email':'text'} maxLength={max} required={key==='name'||key==='firstName'||key==='account'} autoComplete="off" onChange={e=>setProfile({...profile,[key]:e.target.value})}/></Field>)}<Field label="Geburtstag"><input type="date" min="1900-01-01" value={profile.birthdate} onChange={e=>setProfile({...profile,birthdate:e.target.value})}/></Field></div><p className="form-hint">Das Konto erhält zunächst keine Rechte. Die Standardspalten der Kunden-, Kurier-, Auftrags- und Kostenstellenlisten werden angelegt.</p></fieldset>:data&&<><p>Anmeldename: <strong>{data.profile.account}</strong> · Zwei-Faktor-Anmeldung: {data.twoFactorActive?'aktiv':'nicht aktiviert'}</p><div className="admin-row-actions">{allowTwoFactor&&<button type="button" className="button" disabled={locked} onClick={async()=>{if(!dirty||await discard('Ungespeicherte Zugangsdaten verwerfen?')){clear();setFactor(true);}}}>Zwei-Faktor-Anmeldung</button>}<button type="button" className={'button '+(mode==='password'?'primary':'')} disabled={locked} onClick={()=>void changeMode('password')}>Passwort ändern</button><button type="button" className={'button '+(mode==='delete'?'primary':'')} disabled={locked||!data.canDelete} onClick={()=>void changeMode('delete')}>Konto löschen</button></div>{!data.canPassword&&mode==='password'&&<p className="admin-hint">Für dieses Konto dürfen Sie das Passwort nicht ändern.</p>}{!data.canDelete&&<p className="form-hint">Das eigene Konto und Administratorkonten sind vor Löschung geschützt.</p>}</>}
|
||||
{(creating||data)&&(mode==='password'?<fieldset disabled={locked||!creating&&!data?.canPassword}><legend>{creating?'Erstes Passwort':uiText("Neues Passwort")}</legend><div className="form-grid">{data?.self&&<Field label="Aktuelles Passwort"><input type="password" autoComplete="current-password" required value={currentPassword} onChange={e=>setCurrentPassword(e.target.value)}/></Field>}<Field label={uiText("Passwort")}><input type="password" autoComplete="new-password" minLength={8} maxLength={128} required value={password} onChange={e=>setPassword(e.target.value)}/></Field><Field label="Passwort wiederholen"><input type="password" autoComplete="new-password" minLength={8} maxLength={128} required value={confirmation} onChange={e=>setConfirmation(e.target.value)}/></Field></div><p className="form-hint">8 bis 128 Zeichen mit mindestens einem Buchstaben und einer Ziffer. Nach der Änderung ist eine erneute Anmeldung erforderlich.</p></fieldset>:<fieldset disabled={locked||!data?.canDelete}><legend>Konto endgültig löschen</legend><p>Der Mitarbeiterzugang, seine Niederlassungsrechte und persönlichen Listeneinstellungen werden gelöscht. Historische Aufträge und Protokolle bleiben erhalten.</p><Field label="Anmeldenamen zur Bestätigung eingeben"><input autoComplete="off" value={deleteAccount} required onChange={e=>setDeleteAccount(e.target.value)}/></Field></fieldset>)}
|
||||
{base===defaultBase&&(created||employee)&&!(done&&mode==='delete')&&<PersonalLinks kind="employees" id={(created||employee)!} disabled={busy||uncertain||dirty}/>}{uncertain&&<p className="alert info">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und erneut prüfen.</p>}</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button>{stale&&<button type="button" className="button" disabled={busy} onClick={()=>void reload()}>Aktuellen Stand laden</button>}{!done&&<button className="button primary" disabled={busy||stale||(!creating&&!data)||(!uncertain&&(mode==='password'?(!creating&&!data?.canPassword)||!password||password!==confirmation:!data?.canDelete||deleteAccount!==data.profile.account))}>{uncertain?'Ergebnis erneut abrufen':creating?'Mitarbeiter anlegen':mode==='delete'?'Konto endgültig löschen':'Passwort speichern'}</button>}</div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {EmployeeCallCenterDialog} from './EmployeeCallCenter';
|
||||
import {EmployeeStockRightsDialog} from './EmployeeStockRights';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {ApiError,request,put} from './api';
|
||||
import {EmployeeAccountDialog} from './EmployeeAccounts';
|
||||
import {EmployeeListPreferencesDialog} from './EmployeeListPreferences';
|
||||
import {EmployeeHistoryDialog} from './EmployeeHistory';
|
||||
|
||||
type Branch={id:number;label:string;enabled:boolean;canEdit:boolean};
|
||||
type Person={canCallCenter?:boolean;canPreferences?:boolean;canHistory?:boolean;id:number;headquartersId:number;headquartersLabel?:string;name:string;firstName:string;email:string;phone:string;mobile:string;account:string;birthdate:string};
|
||||
type Right={id:number;label:string;enabled:boolean;heritable:boolean;canEdit:boolean;canInherit:boolean};
|
||||
type Cell={headquarters:number;right:number;enabled:boolean;canEdit:boolean};
|
||||
type Detail={profile:Person;revision:string;heritageMode:boolean;canEditProfile:boolean;protectedAccount:boolean;self:boolean;rights:Right[];headquarters:Branch[];matrix:Cell[]};
|
||||
const base='/administration/employees';
|
||||
const key=(c:Cell)=>`${c.headquarters}:${c.right}`;
|
||||
const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b);
|
||||
|
||||
export function EmployeeAdministration({refresh,onDirtyChange}:{refresh:number;onDirtyChange:(v:boolean)=>void}){
|
||||
const uiText=useUiText("admin/employee_list.php");
|
||||
|
||||
const [callCenter,setCallCenter]=useState<number|null>(null);
|
||||
const [stockRights,setStockRights]=useState<number|null>(null);
|
||||
const [account,setAccount]=useState<number|'new'|null>(null);
|
||||
const [history,setHistory]=useState<number|null>(null),[preferences,setPreferences]=useState<number|null>(null);
|
||||
const [branches,setBranches]=useState<Branch[]>([]),[hq,setHq]=useState<number[]|null>(null),[ownHq,setOwnHq]=useState<number|null>(null),[search,setSearch]=useState(''),[query,setQuery]=useState(''),[sort,setSort]=useState('name');
|
||||
const [items,setItems]=useState<Person[]>([]),[truncated,setTruncated]=useState(false),[error,setError]=useState(''),[loading,setLoading]=useState(true),[selected,setSelected]=useState<number|null>(null),[version,setVersion]=useState(0);
|
||||
useEffect(()=>{const c=new AbortController();request<{currentId:number;items:Branch[]}>(base+'/headquarters',{signal:c.signal}).then(d=>{if(!c.signal.aborted){setBranches(d.items);setOwnHq(d.currentId);setHq(old=>old===null?[d.currentId]:old.filter(id=>d.items.some(b=>b.id===id)));}}).catch(e=>{if(!c.signal.aborted){setError(e.message);setLoading(false);}});return()=>c.abort();},[refresh]);
|
||||
useEffect(()=>{if(hq===null)return;const c=new AbortController();setLoading(true);setItems([]);setError('');request<{items:Person[];truncated:boolean}>(base+'?'+new URLSearchParams({headquartersIds:hq.join(','),q:query,sort}),{signal:c.signal}).then(d=>{if(!c.signal.aborted){setItems(d.items);setTruncated(d.truncated);}}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[hq,query,sort,version,refresh]);
|
||||
return <><h2>{uiText("Mitarbeiter")}</h2><p>Zentralenmitarbeiter und ihre allgemeinen, vererbbaren und niederlassungsbezogenen Rechte.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}
|
||||
<form onSubmit={e=>{e.preventDefault();setQuery(search);setVersion(v=>v+1);}}><div className="admin-toolbar">
|
||||
<fieldset disabled={!branches.length}><legend>Niederlassungen</legend><div className="admin-toolbar">{branches.map(b=><label className="admin-check" key={b.id}><input type="checkbox" checked={hq?.includes(b.id)??false} onChange={e=>setHq(v=>e.target.checked?[...(v??[]),b.id]:(v??[]).filter(id=>id!==b.id))}/>{b.label}</label>)}<button type="button" className="button" onClick={()=>setHq(branches.map(b=>b.id))}>{uiText("Alle")}</button><button type="button" className="button" onClick={()=>setHq([])}>{uiText("Keine")}</button></div>{hq?.length===0&&<p className="form-hint">Ohne Auswahl werden die Mitarbeiter der eigenen Niederlassung angezeigt.</p>}</fieldset>
|
||||
<Field label="Mitarbeiter suchen"><input type="search" maxLength={100} value={search} onChange={e=>setSearch(e.target.value)} placeholder="Name, Anmeldename, E-Mail oder Telefon"/></Field>
|
||||
<Field label="Sortierung"><select value={sort} onChange={e=>setSort(e.target.value)}>{[['name','Name'],['firstName','Vorname'],['headquarters','Niederlassung'],['email','E-Mail'],['phone','Telefon'],['account','Anmeldename']].map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></Field>
|
||||
<button className="button" disabled={loading}>{uiText("Aktualisieren")}</button><button type="button" className="button primary" disabled={!branches.length} onClick={()=>setAccount('new')}>{uiText("Neuer Mitarbeiter")}</button></div></form>
|
||||
{loading?<p>Mitarbeiter werden geladen …</p>:<><p>{items.length} {uiText(" Mitarbeiter")}{truncated?' · weitere Treffer vorhanden; bitte die Suche eingrenzen':''}</p><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Name")}</th><th>{uiText("Vorname")}</th><th>Niederlassung</th><th>{uiText("E-Mail")}</th><th>Telefon / Mobil</th><th>{uiText("Anmeldename")}</th><th>{uiText("Aktion")}</th></tr></thead><tbody>{items.map(p=><tr key={p.id}><td>{p.name}</td><td>{p.firstName}</td><td>{p.headquartersLabel}</td><td>{p.email||'–'}</td><td>{p.phone||'–'}{p.mobile&&<><br/>{p.mobile}</>}</td><td>{p.account}</td><td><button className="button" onClick={()=>setSelected(p.id)}>{uiText("Bearbeiten")}</button> <button className="button" onClick={()=>setAccount(p.id)}>Zugang</button> <button className="button" onClick={()=>setStockRights(p.id)}>Lagerrechte</button> {p.canCallCenter&&<button className="button" onClick={()=>setCallCenter(p.id)}>CC24</button>} {p.canPreferences&&<button className="button" onClick={()=>setPreferences(p.id)}>Listenparameter</button>} {p.canHistory&&<button className="button" onClick={()=>setHistory(p.id)}>{uiText("Historie")}</button>}</td></tr>)}</tbody></table></div>{!items.length&&!error&&<p>Keine Mitarbeiter gefunden.</p>}</>}
|
||||
{callCenter!==null&&<EmployeeCallCenterDialog employee={callCenter} onDirtyChange={onDirtyChange} onClose={()=>setCallCenter(null)}/>}
|
||||
{stockRights!==null&&<EmployeeStockRightsDialog employee={stockRights} onDirtyChange={onDirtyChange} onClose={()=>setStockRights(null)}/> }
|
||||
{preferences!==null&&<EmployeeListPreferencesDialog employee={preferences} onDirtyChange={onDirtyChange} onClose={()=>setPreferences(null)}/>}
|
||||
{history!==null&&<EmployeeHistoryDialog employee={history} onClose={()=>setHistory(null)}/>}
|
||||
{account!==null&&<EmployeeAccountDialog employee={account==='new'?null:account} headquarters={hq?.length===1?hq[0]:ownHq} branches={branches} onDirtyChange={onDirtyChange} onClose={()=>{setAccount(null);setVersion(v=>v+1);}}/>}
|
||||
{selected!==null&&<EmployeeEditor employee={selected} onDirtyChange={onDirtyChange} onClose={()=>{setSelected(null);setVersion(v=>v+1);}}/>}</>;
|
||||
}
|
||||
function EmployeeEditor({employee,onClose,onDirtyChange}:{employee:number;onClose:()=>void;onDirtyChange:(v:boolean)=>void}){
|
||||
const uiText=useUiText("admin/employee_list.php");
|
||||
|
||||
const [data,setData]=useState<Detail|null>(null),[profile,setProfile]=useState<Person|null>(null),[rights,setRights]=useState<Right[]>([]),[branches,setBranches]=useState<Branch[]>([]),[matrix,setMatrix]=useState<Cell[]>([]);
|
||||
const [mode,setMode]=useState<'profile'|'rights'>('profile'),[filter,setFilter]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[ack,setAck]=useState(false);
|
||||
const sending=useRef(false),pending=useRef<{path:string;body:unknown}|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&(mode==='profile'?!same(profile,data.profile):!same(rights,data.rights)||!same(branches,data.headquarters)||!same(matrix,data.matrix));
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
function apply(d:Detail){setData(d);setProfile(d.profile);setRights(d.rights);setBranches(d.headquarters);setMatrix(d.matrix);setStale(false);setAck(false);pending.current=null;}
|
||||
useEffect(()=>{const c=new AbortController();request<Detail>(`${base}/${employee}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});return()=>c.abort();},[employee]);
|
||||
async function close(){if(!sending.current&&!uncertain&&(!dirty||await discard('Ungespeicherte Mitarbeiteränderungen verwerfen?')))onClose();}
|
||||
async function switchMode(next:'profile'|'rights'){if(mode===next||sending.current||uncertain)return;if(!dirty||await discard('Ungespeicherte Mitarbeiteränderungen verwerfen?')){if(data)apply(data);setMode(next);setError('');setNotice('');}}
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Mitarbeiteränderungen verwerfen?'))return;sending.current=true;setBusy(true);setError('');try{apply(await request<Detail>(`${base}/${employee}`));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
function changed(){setAck(false);setNotice('');pending.current=null;}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||!profile||sending.current||stale||(!uncertain&&(!dirty||mode==='rights'&&!ack)))return;sending.current=true;setBusy(true);setError('');setNotice('');
|
||||
if(!pending.current){const body=mode==='profile'?{requestId:crypto.randomUUID(),revision:data.revision,name:profile.name,firstName:profile.firstName,email:profile.email,phone:profile.phone,mobile:profile.mobile,account:profile.account,birthdate:profile.birthdate||null}:{requestId:crypto.randomUUID(),revision:data.revision,rights:rights.filter(r=>r.enabled).map(r=>r.id),heritable:rights.filter(r=>r.heritable).map(r=>r.id),headquarters:branches.filter(b=>b.enabled).map(b=>b.id),matrix:matrix.filter(c=>c.enabled).map(c=>({headquarters:c.headquarters,right:c.right}))};pending.current={path:`${base}/${employee}/${mode}`,body};}
|
||||
try{await put(pending.current.path,pending.current.body);setUncertain(false);pending.current=null;setNotice(mode==='rights'?'Rechte gespeichert. Entzogene Rechte gelten bei der nächsten Anfrage; neue Rechte nach erneuter Anmeldung.':'Mitarbeiterdaten gespeichert.');try{apply(await request<Detail>(`${base}/${employee}`));}catch(e){setError('Gespeichert. Der aktuelle Stand konnte nicht geladen werden: '+(e as Error).message);setStale(true);}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
const locked=busy||uncertain||stale;
|
||||
const visible=rights.filter(r=>r.label.toLocaleLowerCase('de').includes(filter.toLocaleLowerCase('de')));
|
||||
function toggleCells(test:(c:Cell)=>boolean,value:boolean){setMatrix(v=>v.map(c=>c.canEdit&&test(c)?{...c,enabled:value}:c));changed();}
|
||||
return <><Modal title={profile?`Mitarbeiter · ${profile.firstName} ${profile.name}`:'Mitarbeiter laden'} wide onClose={()=>void close()}><form onSubmit={save} className="employee-editor">
|
||||
<div className="admin-dialog-body modal-body"><div className="admin-row-actions"><button type="button" className={'button '+(mode==='profile'?'primary':'')} disabled={busy||uncertain} onClick={()=>void switchMode('profile')}>Stammdaten</button><button type="button" className={'button '+(mode==='rights'?'primary':'')} disabled={busy||uncertain} onClick={()=>void switchMode('rights')}>Rechte und Niederlassungen</button></div>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{!data&&!error&&<p>Mitarbeiter werden geladen …</p>}
|
||||
{data&&profile&&<>{data.protectedAccount&&<p className="admin-hint">Dieses Administratorkonto ist geschützt.</p>}{mode==='profile'?<fieldset disabled={locked||!data.canEditProfile}><legend>Persönliche Angaben</legend><div className="form-grid">{([['name','Name',50],['firstName','Vorname',50],['email','E-Mail',250],['phone','Telefon',20],['mobile','Mobil',20],['account','Anmeldename',20]] as const).map(([k,label,max])=><Field key={k} label={uiText(label)}><input required={k==='name'||k==='firstName'||k==='account'} type={k==='email'?'email':'text'} autoComplete="off" maxLength={max} value={profile[k]} onChange={e=>{setProfile({...profile,[k]:e.target.value});changed();}}/></Field>)}<Field label="Geburtstag"><input type="date" min="1900-01-01" value={profile.birthdate} onChange={e=>{setProfile({...profile,birthdate:e.target.value});changed();}}/></Field></div></fieldset>:<>
|
||||
{data.self&&<p className="admin-hint">Die eigenen Rechte und Niederlassungsfreigaben sind geschützt.</p>}<p>Allgemeine Rechte bestimmen den Zugriff. „Darf Recht erteilen“ erlaubt die Weitergabe. Niederlassungsrechte gelten zusätzlich für die jeweiligen Bereiche. Gesperrte Felder bleiben erhalten.</p>
|
||||
<fieldset disabled={locked}><legend>Niederlassungsfreigaben</legend><div className="admin-toolbar">{branches.map(b=><label className="admin-check" key={b.id}><input type="checkbox" disabled={!b.canEdit} checked={b.enabled} onChange={e=>{setBranches(v=>v.map(x=>x.id===b.id?{...x,enabled:e.target.checked}:x));changed();}}/>{b.label}</label>)}</div><p className="form-hint">Bleibt keine Freigabe übrig, wird die eigene Niederlassung des Mitarbeiters verwendet.</p></fieldset>
|
||||
<Field label="Rechte filtern"><input type="search" value={filter} onChange={e=>setFilter(e.target.value)} placeholder="z. B. Mitarbeiter oder Aufträge"/></Field>
|
||||
<fieldset disabled={locked}><legend>Allgemeine Rechte{data.heritageMode?' und Vererbung':' · klassischer Modus'}</legend><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Recht</th><th>Berechtigt</th>{data.heritageMode&&<th>Darf Recht erteilen</th>}</tr></thead><tbody>{visible.map(r=><tr key={r.id}><td>{r.label}</td><td><input type="checkbox" aria-label={`${r.label}: berechtigt`} disabled={!r.canEdit} checked={r.enabled} onChange={e=>{setRights(v=>v.map(x=>x.id===r.id?{...x,enabled:e.target.checked}:x));changed();}}/></td>{data.heritageMode&&<td><input type="checkbox" aria-label={`${r.label}: darf Recht erteilen`} disabled={!r.canInherit} checked={r.heritable} onChange={e=>{setRights(v=>v.map(x=>x.id===r.id?{...x,heritable:e.target.checked}:x));changed();}}/></td>}</tr>)}</tbody></table></div></fieldset>
|
||||
<fieldset disabled={locked}><legend>Rechte je Niederlassung</legend><div className="admin-table-wrap"><table className="admin-table employee-matrix"><thead><tr><th>Recht</th>{branches.map(b=><th key={b.id}>{b.label}<br/><input type="checkbox" aria-label={`${b.label}: alle sichtbaren änderbaren Rechte`} disabled={!matrix.some(c=>c.headquarters===b.id&&c.canEdit&&visible.some(r=>r.id===c.right))} checked={matrix.filter(c=>c.headquarters===b.id&&c.canEdit&&visible.some(r=>r.id===c.right)).length>0&&matrix.filter(c=>c.headquarters===b.id&&c.canEdit&&visible.some(r=>r.id===c.right)).every(c=>c.enabled)} onChange={e=>toggleCells(c=>c.headquarters===b.id&&visible.some(r=>r.id===c.right),e.target.checked)}/></th>)}<th>{uiText("Alle")}</th></tr></thead><tbody>{visible.map(r=><tr key={r.id}><td>{r.label}</td>{branches.map(b=>{const c=matrix.find(c=>c.headquarters===b.id&&c.right===r.id)!;return <td key={b.id}><input type="checkbox" aria-label={`${r.label} in ${b.label}`} checked={c.enabled} disabled={!c.canEdit} onChange={e=>{setMatrix(v=>v.map(x=>key(x)===key(c)?{...x,enabled:e.target.checked}:x));changed();}}/></td>})}<td><input type="checkbox" aria-label={`${r.label}: alle änderbaren Niederlassungen`} disabled={!matrix.some(c=>c.right===r.id&&c.canEdit)} checked={matrix.filter(c=>c.right===r.id&&c.canEdit).length>0&&matrix.filter(c=>c.right===r.id&&c.canEdit).every(c=>c.enabled)} onChange={e=>toggleCells(c=>c.right===r.id,e.target.checked)}/></td></tr>)}</tbody></table></div></fieldset>
|
||||
{!visible.length&&<p>Keine Rechte für diesen Filter.</p>}<label className="admin-check"><input type="checkbox" disabled={locked||!dirty} checked={ack} onChange={e=>setAck(e.target.checked)}/>Änderungen an Rechten und Niederlassungsfreigaben geprüft</label></>}
|
||||
</>}{uncertain&&<p className="alert info" role="status">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Änderungen erneut prüfen.</p>}</div>
|
||||
<div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={busy||stale||(!uncertain&&(!dirty||mode==='rights'&&!ack))}>{uncertain?'Ergebnis erneut abrufen':mode==='rights'?'Rechte speichern':'Stammdaten speichern'}</button></div>
|
||||
</form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {ApiError,put,request} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
type View={employee:{firstName:string;name:string;account:string};revision:string;enabled:boolean;canSave:boolean;protectedAccount:boolean};
|
||||
type Command={requestId:string;revision:string;enabled:boolean};
|
||||
export function EmployeeCallCenterDialog({employee,onClose,onDirtyChange}:{employee:number;onClose:()=>void;onDirtyChange:(dirty:boolean)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`/administration/employees/${employee}/call-center`;
|
||||
const [data,setData]=useState<View|null>(null),[enabled,setEnabled]=useState(false),[ack,setAck]=useState(false),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<Command|null>(null),generation=useRef(0);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&enabled!==data.enabled;
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
function apply(v:View){setData(v);setEnabled(v.enabled);setAck(false);setStale(false);pending.current=null;}
|
||||
useEffect(()=>{const c=new AbortController(),v=++generation.current;setBusy(true);request<View>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted&&v===generation.current)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base]);
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherten CC24-Zugang verwerfen?'))return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');try{apply(await request<View>(base));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function close(){if(!sending.current&&!uncertain&&(!dirty||await discard('Ungespeicherten CC24-Zugang verwerfen?')))onClose();}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||sending.current||stale||!data.canSave||!uncertain&&(!dirty||!ack))return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');
|
||||
if(!pending.current)pending.current={enabled,requestId:crypto.randomUUID(),revision:data.revision};
|
||||
try{await put(base,pending.current);pending.current=null;setUncertain(false);setNotice('CC24-Zugang gespeichert. Die Freigabe wird bei der nächsten Anfrage geprüft.');try{apply(await request<View>(base));}catch(e){setError('Gespeichert. Der aktuelle Stand konnte nicht geladen werden: '+(e as Error).message);setStale(true);}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
return <><Modal title="CC24-Zugang" onClose={()=>void close()}><form onSubmit={save}><div className="admin-dialog-body modal-body">
|
||||
{data&&<p>{data.employee.firstName} {data.employee.name} · {data.employee.account}</p>}
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{!data&&busy&&<p>CC24-Zugang wird geladen …</p>}
|
||||
{data&&<><p>CC24 erlaubt die Auftragserfassung für aktive Niederlassungen desselben Mandanten. Die fachlichen Rechte des Mitarbeiters gelten weiterhin. Die Freigabe wird auch von der PHP-Anwendung verwendet.</p>
|
||||
<fieldset disabled={busy||uncertain||stale||!data.canSave}><legend>Niederlassungsübergreifender Zugang</legend><Field label="CC24-Zugang"><select value={enabled?'1':'0'} onChange={e=>{setEnabled(e.target.value==='1');setAck(false);setNotice('');pending.current=null;}}><option value="0">{uiText("Nein")}</option><option value="1">{uiText("Ja")}</option></select></Field>
|
||||
<label className="admin-check"><input type="checkbox" disabled={!dirty} checked={ack} onChange={e=>setAck(e.target.checked)}/>Änderung des niederlassungsübergreifenden Zugangs geprüft</label></fieldset>
|
||||
{data.protectedAccount&&<p className="form-hint">Der CC24-Zugang dieses Administratorkontos ist geschützt.</p>}</>}
|
||||
{uncertain&&<p className="alert info" role="status">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Änderung erneut prüfen.</p>}
|
||||
</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={!data?.canSave||busy||stale||!uncertain&&(!dirty||!ack)}>{uncertain?'Ergebnis erneut abrufen':'CC24-Zugang speichern'}</button></div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {request} from './api';
|
||||
|
||||
type Entry={key:string;at:string;action:string;actor:string;phone:string;detail:string;pending:boolean};
|
||||
type History={employee:{firstName:string;name:string};from:string;to:string;source:string;sources:string[];items:Entry[];truncated:boolean};
|
||||
const titles:Record<string,string>={log:'Aktueller Bestand',loghistory:'Archiv',loghistory2:'Archiv 2',loghistory3:'Archiv 3'};
|
||||
function today(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;}
|
||||
export function EmployeeHistoryDialog({employee,onClose}:{employee:number;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [from,setFrom]=useState(today),[to,setTo]=useState(today),[source,setSource]=useState('log');
|
||||
const [query,setQuery]=useState({from:today(),to:today(),source:'log',version:0});
|
||||
const [data,setData]=useState<History|null>(null),[busy,setBusy]=useState(true),[error,setError]=useState('');
|
||||
useEffect(()=>{const c=new AbortController();setBusy(true);setError('');setData(null);request<History>(`/administration/employees/${employee}/history?`+new URLSearchParams({from:query.from,to:query.to,source:query.source}),{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[employee,query]);
|
||||
return <Modal wide title={data?`Mitarbeiterhistorie · ${data.employee.firstName} ${data.employee.name}`:'Mitarbeiterhistorie'} onClose={onClose}>
|
||||
<div className="modal-body admin-dialog-body"><form className="admin-toolbar" onSubmit={e=>{e.preventDefault();if(from&&to&&from<=to)setQuery(q=>({from,to,source,version:q.version+1}));}}>
|
||||
<Field label="Von"><input type="date" required min="1900-01-01" max={to||'9998-12-31'} value={from} onChange={e=>setFrom(e.target.value)}/></Field>
|
||||
<Field label="Bis"><input type="date" required min={from||'1900-01-01'} max="9998-12-31" value={to} onChange={e=>setTo(e.target.value)}/></Field>
|
||||
<Field label="Historienbestand"><select value={source} onChange={e=>setSource(e.target.value)}>{(data?.sources??Object.keys(titles)).map(k=><option key={k} value={k}>{titles[k]}</option>)}</select></Field>
|
||||
<button className="button primary" disabled={busy||!from||!to||from>to}>{uiText("Aktualisieren")}</button></form>
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p>Historie wird geladen …</p>}
|
||||
{data&&<><p>{data.items.length} {data.items.length===1?'Eintrag':'Einträge'}{data.truncated?' · Anzeige auf die neuesten 2500 Einträge begrenzt; bitte den Zeitraum eingrenzen.':''}</p>
|
||||
<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>{uiText("Vorgang")}</th><th>{uiText("Zeitpunkt")}</th><th>Geändert durch</th><th>{uiText("Telefon")}</th><th>{uiText("Details")}</th></tr></thead><tbody>{data.items.map(r=><tr key={r.key}><td>{r.action}{r.pending&&<p className="form-hint">Gespeichert · Protokollübertragung steht aus</p>}</td><td>{new Date(r.at).toLocaleString('de-DE')}</td><td>{r.actor}</td><td>{r.phone||'–'}</td><td style={{whiteSpace:'pre-wrap',minWidth:240}}>{r.detail}</td></tr>)}</tbody></table></div>{!data.items.length&&<p>Keine Einträge im gewählten Zeitraum und Bestand.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" onClick={onClose}>{uiText("Schließen")}</button></div></Modal>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {MasterListColumnsDialog} from './MasterListColumns';
|
||||
import {CostCenterColumnsDialog} from './CostCenterColumns';
|
||||
import {ParallelListsSettingsDialog} from './ParallelListsSettings';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {ApiError,put,request} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
export type ListValues={pageSize:number;vehicleMax:number;copyNumber:boolean;keepRefresh:boolean;courierNotes:boolean;gray:boolean;copyAccounting:boolean;laterAccounting:boolean};
|
||||
export type ListPreferences={employee:{firstName:string;name:string};headquartersId:number;values:ListValues;initialDates?:{from:string;to:string};vehicles:{id:number;label:string}[];revision:string;canSave:boolean;warning:string};
|
||||
export function EmployeeListPreferencesDialog({employee,onClose,onDirtyChange,onSaved}:{employee?:number;onClose:()=>void;onDirtyChange:(v:boolean)=>void;onSaved?:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [masterColumns,setMasterColumns]=useState<'customers'|'couriers'|'orders'|'articles'|null>(null);
|
||||
const [parallel,setParallel]=useState(false),[columns,setColumns]=useState(false);
|
||||
const base=employee?`/administration/employees/${employee}/list-preferences`:'/account/list-preferences';
|
||||
const [data,setData]=useState<ListPreferences|null>(null),[draft,setDraft]=useState<ListValues>({pageSize:100,vehicleMax:0,copyNumber:false,keepRefresh:false,courierNotes:false,gray:false,copyAccounting:false,laterAccounting:false}),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<{requestId:string;revision:string;values:ListValues}|null>(null),generation=useRef(0);
|
||||
const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&JSON.stringify(draft)!==JSON.stringify(data.values);
|
||||
const protectedDraft=useRef(false);protectedDraft.current=dirty||busy||uncertain;
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
function apply(d:ListPreferences){setData(d);setDraft(d.values);setStale(false);pending.current=null;}
|
||||
useEffect(()=>{if(protectedDraft.current)return;const c=new AbortController(),version=++generation.current;setError('');setBusy(true);request<ListPreferences>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted&&version===generation.current)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base]);
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Listenparameter verwerfen?'))return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');try{apply(await request<ListPreferences>(base));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
function change(key:keyof ListValues,value:number|boolean){setDraft(v=>({...v,[key]:value}));setNotice('');pending.current=null;}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||sending.current||stale||!data.canSave||!uncertain&&!dirty)return;
|
||||
sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');
|
||||
if(!pending.current)pending.current={requestId:crypto.randomUUID(),revision:data.revision,values:draft};
|
||||
try{await put(base,pending.current);pending.current=null;setUncertain(false);setNotice('Listenparameter gespeichert.');onSaved?.();try{apply(await request<ListPreferences>(base));}catch(e){setError('Gespeichert. Der aktuelle Stand konnte nicht geladen werden: '+(e as Error).message);setStale(true);}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
async function close(){if(sending.current||uncertain)return;if(!dirty||await discard('Ungespeicherte Listenparameter verwerfen?'))onClose();}
|
||||
if(masterColumns)return <MasterListColumnsDialog kind={masterColumns} employee={employee} onClose={()=>setMasterColumns(null)} onDirtyChange={onDirtyChange} onSaved={onSaved}/>;
|
||||
if(columns)return <CostCenterColumnsDialog employee={employee} onClose={()=>setColumns(false)} onDirtyChange={onDirtyChange} onSaved={onSaved}/>;
|
||||
if(parallel)return <ParallelListsSettingsDialog employee={employee} onClose={()=>setParallel(false)} onDirtyChange={onDirtyChange} onSaved={onSaved}/>;
|
||||
return <><Modal title="Persönliche Listen- und Erfassungsparameter" onClose={()=>void close()}><form onSubmit={save}><div className="admin-dialog-body modal-body">
|
||||
<button type="button" className="button" disabled={busy||uncertain} onClick={async()=>{if(!dirty||await discard('Ungespeicherte Listenparameter verwerfen?')){if(data)apply(data);setParallel(true);}}}>Parallele Listen und Startstatus</button>
|
||||
<button type="button" className="button" disabled={busy||uncertain} onClick={async()=>{if(!dirty||await discard('Ungespeicherte Listenparameter verwerfen?')){if(data)apply(data);setColumns(true);}}}>Spalten der Kostenstellenauswahl</button>
|
||||
{(['orders','customers','couriers','articles'] as const).map(kind=><button key={kind} type="button" className="button" disabled={busy||uncertain} onClick={async()=>{if(!dirty||await discard('Ungespeicherte Listenparameter verwerfen?')){if(data)apply(data);setMasterColumns(kind);}}}>{kind==='articles'?'Spalten der Artikelliste':kind==='orders'?'Spalten der Auftragslisten':kind==='customers'?'Spalten der Kundenliste':'Spalten der Transporteursliste'}</button>)}
|
||||
{data&&<p>{data.employee.firstName} {data.employee.name} · Vorgaben für Niederlassung {data.headquartersId}</p>}
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{data?.warning&&<p className="alert info">{data.warning}</p>}
|
||||
{!data&&busy&&<p>Listenparameter werden geladen …</p>}
|
||||
{data&&<fieldset disabled={busy||uncertain||stale||!data.canSave}><legend>Auftragsliste</legend><div className="form-grid">
|
||||
<Field label="Einträge je Listenseite"><select value={draft.pageSize} onChange={e=>change('pageSize',Number(e.target.value))}>{draft.pageSize%100!==0&&<option value={draft.pageSize}>Altwert: {draft.pageSize}</option>}{Array.from({length:10},(_,i)=><option key={i} value={(i+1)*100}>{(i+1)*100}</option>)}</select></Field>
|
||||
<Field label="Fahrzeugtypen bis einschließlich"><select value={draft.vehicleMax} onChange={e=>change('vehicleMax',Number(e.target.value))}><option value={0}>Alle Fahrzeugtypen</option>{draft.vehicleMax>0&&!data.vehicles.some(v=>v.id===draft.vehicleMax)&&<option value={draft.vehicleMax}>Altwert: {draft.vehicleMax}</option>}{data.vehicles.map(v=><option key={v.id} value={v.id}>{v.label}</option>)}</select></Field>
|
||||
<Field label="Auftragsnummer übernehmen"><select value={draft.copyNumber?'1':'0'} onChange={e=>change('copyNumber',e.target.value==='1')}><option value="0">Keine Übernahme</option><option value="1">Von-Nummer in Bis-Nummer übernehmen</option></select></Field>
|
||||
<Field label="Automatische Aktualisierung nach Neuladen"><select value={draft.keepRefresh?'1':'0'} onChange={e=>change('keepRefresh',e.target.value==='1')}><option value="0">Immer aktiv für offene / laufende Aufträge</option><option value="1">Auswahl beibehalten</option></select></Field>
|
||||
<Field label="Transporteurdaten einbeziehen"><select value={draft.courierNotes?'1':'0'} onChange={e=>change('courierNotes',e.target.value==='1')}><option value="0">{uiText("Nein")}</option><option value="1">{uiText("Ja")}</option></select></Field>
|
||||
<Field label="Hintergrunddarstellung"><select value={draft.gray?'1':'0'} onChange={e=>change('gray',e.target.value==='1')}><option value="0">Blau</option><option value="1">Grau</option></select></Field>
|
||||
<Field label="Modus Auftragskopie"><select value={draft.copyAccounting?'1':'0'} onChange={e=>change('copyAccounting',e.target.value==='1')}><option value="0">{uiText("Disposition")}</option><option value="1">{uiText("Buchhaltung")}</option></select></Field>
|
||||
<Field label="Modus Nacherfassung"><select value={draft.laterAccounting?'1':'0'} onChange={e=>change('laterAccounting',e.target.value==='1')}><option value="0">{uiText("Disposition")}</option><option value="1">{uiText("Buchhaltung")}</option></select></Field>
|
||||
</div><p className="form-hint">Die Fahrzeuggrenze gilt, solange kein einzelner Fahrzeugtyp gefiltert wird. Transporteurbemerkungen erscheinen bei „Unterwegs“ und „Erledigt“ mit Transporteurberechtigung. Für alle oder erledigte Aufträge startet keine automatische Aktualisierung.</p><p className="form-hint">Im Buchhaltungsmodus startet die Erfassung als Nacherfassung. Auftragskopien für die Buchhaltung übernehmen Ausführungszeit und ein weiterhin verfügbares Fahrzeug zur erneuten Prüfung. Beide Vorgaben können für eine einzelne Erfassung auf Disposition umgestellt werden.</p></fieldset>}
|
||||
{uncertain&&<p className="alert info" role="status">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Änderungen erneut prüfen.</p>}
|
||||
</div><div className="modal-footer"><button className="button" type="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button className="button" type="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={!data||!data.canSave||busy||stale||!uncertain&&!dirty}>{uncertain?'Ergebnis erneut abrufen':'Listenparameter speichern'}</button></div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState,type FormEvent} from 'react';
|
||||
import {ApiError,put,request} from './api';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
type Option={id:number;label:string};
|
||||
type Grant={root:number;name:string;headquartersId:number;enabled:boolean;subtrees:number[];selectedLabels:Option[];canAccess:boolean;canSubtrees:boolean;canUnrestrict:boolean;options:Option[]};
|
||||
type View={employee:{firstName:string;name:string};headquartersId:number;revision:string;grants:Grant[];readOnly:boolean;articles:boolean;canSave:boolean;canReadOnly:boolean;canArticles:boolean;warning:string};
|
||||
type Values={grants:{root:number;enabled:boolean;subtrees:number[]}[];readOnly:boolean;articles:boolean};
|
||||
const values=(d:View):Values=>({grants:d.grants.map(g=>({root:g.root,enabled:g.enabled,subtrees:g.subtrees})),readOnly:d.readOnly,articles:d.articles});
|
||||
export function EmployeeStockRightsDialog({employee,onClose,onDirtyChange,basePath='/administration/employees'}:{basePath?:string;employee:number;onClose:()=>void;onDirtyChange:(dirty:boolean)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const base=`${basePath}/${employee}/stock-rights`;
|
||||
const [data,setData]=useState<View|null>(null),[draft,setDraft]=useState<Values|null>(null),[filter,setFilter]=useState(''),[selected,setSelected]=useState<Record<number,number>>({});
|
||||
const [busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[stale,setStale]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const sending=useRef(false),pending=useRef<(Values&{requestId:string;revision:string})|null>(null),generation=useRef(0);
|
||||
const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const dirty=!!data&&JSON.stringify(draft)!==JSON.stringify(values(data));
|
||||
useEffect(()=>{onDirtyChange(dirty||busy||uncertain);return()=>onDirtyChange(false);},[dirty,busy,uncertain,onDirtyChange]);
|
||||
function apply(v:View){setData(v);setDraft(values(v));setStale(false);setSelected({});pending.current=null;}
|
||||
useEffect(()=>{const c=new AbortController(),v=++generation.current;setBusy(true);request<View>(base,{signal:c.signal}).then(d=>{if(!c.signal.aborted&&v===generation.current)apply(d);}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});return()=>c.abort();},[base]);
|
||||
function change(next:Values){setDraft(next);setNotice('');pending.current=null;}
|
||||
function grant(root:number,patch:Partial<Values['grants'][number]>){if(draft)change({...draft,grants:draft.grants.map(g=>g.root===root?{...g,...patch}:g)});}
|
||||
async function reload(){if(sending.current||uncertain||dirty&&!await discard('Ungespeicherte Lagerrechte verwerfen?'))return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');try{apply(await request<View>(base));}catch(e){setError((e as Error).message);}finally{sending.current=false;setBusy(false);}}
|
||||
async function close(){if(!sending.current&&!uncertain&&(!dirty||await discard('Ungespeicherte Lagerrechte verwerfen?')))onClose();}
|
||||
async function save(e:FormEvent){e.preventDefault();if(!data||!draft||sending.current||stale||!data.canSave||!uncertain&&!dirty)return;sending.current=true;generation.current++;setBusy(true);setError('');setNotice('');
|
||||
if(!pending.current)pending.current={...draft,requestId:crypto.randomUUID(),revision:data.revision};
|
||||
try{await put(base,pending.current);pending.current=null;setUncertain(false);setNotice('Lagerrechte gespeichert. Sie gelten bei der nächsten Lageranfrage.');try{apply(await request<View>(base));}catch(e){setError('Gespeichert. Der aktuelle Stand konnte nicht geladen werden: '+(e as Error).message);setStale(true);}}
|
||||
catch(e){setError((e as Error).message);const unknown=!(e instanceof ApiError)||e.status===0||e.status>=500;setUncertain(unknown);setStale(!unknown);}
|
||||
finally{sending.current=false;setBusy(false);}
|
||||
}
|
||||
const locked=busy||uncertain||stale||!data?.canSave;
|
||||
return <><Modal title="Lagerrechte des Mitarbeiters" wide onClose={()=>void close()}><form onSubmit={save}><div className="admin-dialog-body modal-body">
|
||||
{data&&<p>{data.employee.firstName} {data.employee.name} · Vorgaben für Niederlassung {data.headquartersId}</p>}
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="admin-success" role="status">{notice}</p>}{data?.warning&&<p className="form-hint">{data.warning}</p>}{!data&&busy&&<p>Lagerrechte werden geladen …</p>}
|
||||
{data&&draft&&<><fieldset disabled={locked}><legend>Zugangsart</legend><div className="form-grid">
|
||||
<Field label="Nur Leserechte in freigegebenen Unterlägern"><select disabled={!data.canReadOnly} value={draft.readOnly?'1':'0'} onChange={e=>change({...draft,readOnly:e.target.value==='1'})}><option value="0">Nein – Lagerbuchungen zulassen</option><option value="1">Ja – keine Lagerbuchungen</option></select></Field>
|
||||
<Field label="Berechtigung zur Artikelverwaltung"><select disabled={!data.canArticles} value={draft.articles?'1':'0'} onChange={e=>change({...draft,articles:e.target.value==='1'})}><option value="0">{uiText("Nein")}</option><option value="1">{uiText("Ja")}</option></select></Field>
|
||||
</div><p className="form-hint">Die Artikelberechtigung gilt für Lagerkonten. Mitarbeiter der Zentrale benötigen für die Artikelverwaltung zusätzlich das allgemeine Lagerrecht.</p></fieldset>
|
||||
<Field label="Hauptlager filtern"><input type="search" value={filter} onChange={e=>setFilter(e.target.value)} placeholder="Lagername oder Kennung"/></Field>
|
||||
{data.grants.filter(g=>`${g.name} ${g.root}`.toLocaleLowerCase('de').includes(filter.toLocaleLowerCase('de'))).map(g=>{const d=draft.grants.find(x=>x.root===g.root)!;return <fieldset key={g.root} disabled={locked}><legend>{g.name} · #{g.root} · NL {g.headquartersId}</legend>
|
||||
<label className="admin-check"><input type="checkbox" disabled={!g.canAccess} checked={d.enabled} onChange={e=>grant(g.root,{enabled:e.target.checked})}/>Zugang zum Hauptlager</label>
|
||||
<p>{d.subtrees.length?'Zugelassene Unterlager einschließlich ihrer Nachfolger:':'Keine Einschränkung auf Unterlager.'}</p>
|
||||
{d.subtrees.length>0&&<ul>{d.subtrees.map(id=><li key={id}>{g.options.find(o=>o.id===id)?.label||g.selectedLabels.find(o=>o.id===id)?.label||`Lager #${id}`} <button type="button" className="button" disabled={!g.canSubtrees||d.subtrees.length===1&&!g.canUnrestrict} onClick={()=>grant(g.root,{subtrees:d.subtrees.filter(n=>n!==id)})} aria-label={`Unterlager ${id} entfernen`}>Entfernen</button></li>)}</ul>}
|
||||
{g.canSubtrees&&<div className="admin-toolbar"><Field label={`Unterlager für ${g.name}`}><select value={selected[g.root]||0} onChange={e=>setSelected(v=>({...v,[g.root]:Number(e.target.value)}))}><option value={0}>Unterlager auswählen</option>{g.options.filter(o=>o.id!==g.root&&!d.subtrees.includes(o.id)).map(o=><option key={o.id} value={o.id}>{o.label} (#{o.id})</option>)}</select></Field><button type="button" className="button" disabled={!selected[g.root]} onClick={()=>{const id=selected[g.root];if(id){grant(g.root,{subtrees:[...d.subtrees,id].sort((a,b)=>a-b)});setSelected(v=>({...v,[g.root]:0}));}}}>Unterlager hinzufügen</button><button type="button" className="button" disabled={!g.canUnrestrict||!d.subtrees.length} onClick={()=>grant(g.root,{subtrees:[]})}>Unterlagereinschränkung aufheben</button></div>}
|
||||
{!g.canAccess&&<p className="form-hint">Diese Freigabe können Sie nicht ändern.</p>}{g.canAccess&&!g.canSubtrees&&<p className="form-hint">Die bestehenden Unterlagerrechte überschreiten Ihren eigenen Bereich und bleiben erhalten.</p>}
|
||||
</fieldset>;})}</>}
|
||||
{uncertain&&<p className="alert info" role="status">Die Speicherbestätigung fehlt. Bitte das Ergebnis mit derselben Vorgangskennung erneut abrufen.</p>}{stale&&<p>Bitte den aktuellen Stand laden und die Änderungen erneut prüfen.</p>}
|
||||
</div><div className="modal-footer"><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void close()}>{uiText("Schließen")}</button><button type="button" className="button" disabled={busy||uncertain} onClick={()=>void reload()}>Aktuellen Stand laden</button><button className="button primary" disabled={!data?.canSave||busy||stale||!uncertain&&!dirty}>{uncertain?'Ergebnis erneut abrufen':'Lagerrechte speichern'}</button></div></form></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {useEntryApi} from './EntryScope';
|
||||
import {Field} from './FormElements';
|
||||
|
||||
type Vehicle={id:number;courierId:number;sid:string;name:string;vehicleTypeId:number;plate:string};
|
||||
type Parents={enabled:boolean;preferredSid:string;vehicles:Vehicle[]};
|
||||
export function EntryCourierParent({vehicleType,vehicle,active,disabled,onSelect}:{vehicleType:string;vehicle:Vehicle|null;active:boolean;disabled:boolean;onSelect:(value:Vehicle)=>void}) {
|
||||
const {request}=useEntryApi();
|
||||
const key=active&&vehicleType!==''?vehicleType:'';
|
||||
const [result,setResult]=useState<{key:string;data:Parents}|null>(null),[error,setError]=useState(''),[retry,setRetry]=useState(0);
|
||||
useEffect(()=>{
|
||||
const c=new AbortController();setResult(null);setError('');
|
||||
if(!key)return;
|
||||
request<Parents>(`/orders/entry/courier-parents?vehicleType=${encodeURIComponent(key)}`,{signal:c.signal})
|
||||
.then(data=>{if(!c.signal.aborted)setResult({key,data});}).catch(e=>{if(!c.signal.aborted)setError(e.message);});
|
||||
return()=>c.abort();
|
||||
},[key,request,retry]);
|
||||
if(!key)return null;
|
||||
if(error)return <p className="alert error" role="alert">{error} <button type="button" className="text-button" disabled={disabled} onClick={()=>setRetry(v=>v+1)}>Verrechnungsfahrzeuge erneut laden</button></p>;
|
||||
const data=result?.key===key?result.data:null;
|
||||
if(!data)return <p role="status">Verrechnungsfahrzeuge werden geladen …</p>;
|
||||
if(!data.enabled)return null;
|
||||
const selected=data.vehicles.find(v=>v.id===vehicle?.id),preferred=data.vehicles.filter(v=>v.sid===data.preferredSid);
|
||||
return <div className="entry-feature-row" aria-label="Verrechnungsfahrzeug für Unteraufträge">
|
||||
<p className="form-hint">Bei Unternehmerverrechnung wird die Gegenbuchung dem Verrechnungsfahrzeug zugeordnet. Die Auswahl ersetzt das ausführende Fahrzeug des Hauptauftrags; die Fahrzeugklasse bleibt erhalten.</p>
|
||||
<Field label="Verrechnungsfahrzeug"><select value={selected?.id??''} disabled={disabled} onChange={e=>{const value=data.vehicles.find(v=>v.id===Number(e.target.value));if(value)onSelect(value);}}>
|
||||
<option value="">Bitte wählen</option>{data.vehicles.map(v=><option key={v.id} value={v.id}>{v.sid} · {v.name}{v.plate?` · ${v.plate}`:''}</option>)}
|
||||
</select></Field>
|
||||
{preferred.length===1?<button type="button" className="text-button" disabled={disabled||selected?.id===preferred[0].id} onClick={()=>onSelect(preferred[0])}>Vorgabe {data.preferredSid} übernehmen</button>:<p className="alert">Das vorgegebene Fahrzeug {data.preferredSid} ist nicht eindeutig verfügbar. Bitte ein verfügbares Verrechnungsfahrzeug auswählen oder die Stammdaten prüfen.</p>}
|
||||
{!selected&&<p className="alert" role="status">Für Unteraufträge muss ein Verrechnungsfahrzeug gewählt werden.{vehicle?` Aktuell ausgewählt: ${vehicle.sid}.`:''}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {useEntryApi} from './EntryScope';
|
||||
|
||||
type Notice={costCenterId:number;customerId:number;customerNumber:string;customerName:string;remark:string};
|
||||
type Assignment={id:number;label:string};
|
||||
export function EntryCustomerNotices({assignments,disabled}:{assignments:Assignment[];disabled:boolean}) {
|
||||
const {request}=useEntryApi();
|
||||
const key=[...new Set(assignments.map(a=>a.id))].sort((a,b)=>a-b).join(',');
|
||||
const [result,setResult]=useState<{key:string;items:Notice[]}|null>(null),[error,setError]=useState(''),[retry,setRetry]=useState(0),[busy,setBusy]=useState(false);
|
||||
useEffect(()=>{
|
||||
const c=new AbortController();setResult(null);setError('');
|
||||
if(!key){setBusy(false);return;}
|
||||
setBusy(true);
|
||||
request<Notice[]>(`/orders/entry/customer-notices?costCenterIds=${key}`,{signal:c.signal})
|
||||
.then(items=>{if(!c.signal.aborted)setResult({key,items});}).catch(e=>{if(!c.signal.aborted)setError(e.message);}).finally(()=>{if(!c.signal.aborted)setBusy(false);});
|
||||
return()=>c.abort();
|
||||
},[key,request,retry]);
|
||||
if(!key)return null;
|
||||
const groups=new Map<number,{notice:Notice;labels:string[]}>();
|
||||
if(result?.key===key)for(const notice of result.items){
|
||||
if(!notice.remark.trim())continue;
|
||||
const group=groups.get(notice.customerId)??{notice,labels:[]};
|
||||
for(const a of assignments)if(a.id===notice.costCenterId&&!group.labels.includes(a.label))group.labels.push(a.label);
|
||||
groups.set(notice.customerId,group);
|
||||
}
|
||||
if(!busy&&!error&&!groups.size)return null;
|
||||
return <section className="entry-customer-notices" aria-label="Kundenhinweise aus dem Firmenstamm">
|
||||
<h4>Kundenhinweise aus dem Firmenstamm</h4>
|
||||
{busy&&<p role="status">Kundenhinweise werden geladen …</p>}
|
||||
{error&&<p className="alert error" role="alert">{error} <button type="button" className="text-button" disabled={disabled} onClick={()=>setRetry(v=>v+1)}>Kundenhinweise erneut laden</button></p>}
|
||||
{[...groups.values()].map(({notice,labels})=><article className="alert info" key={notice.customerId}><strong>{notice.customerNumber} · {notice.customerName}</strong><p className="form-hint">{labels.join(' · ')}</p><div style={{whiteSpace:'pre-wrap',overflowWrap:'anywhere'}}>{notice.remark}</div></article>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {useEntryApi} from './EntryScope';
|
||||
|
||||
/** The existing receipt history is scoped to this employee and entry headquarters. */
|
||||
export function EntryLastOrder({disabled,onSelect}:{disabled:boolean;onSelect:(id:number)=>void}) {
|
||||
const {request}=useEntryApi();
|
||||
const [last,setLast]=useState<number|null>(null),[busy,setBusy]=useState(true),[error,setError]=useState(''),[retry,setRetry]=useState(0);
|
||||
useEffect(()=>{
|
||||
const c=new AbortController();setBusy(true);setLast(null);setError('');
|
||||
request<{items:{order:number}[]}>('/orders/entry/request-mail/recent',{signal:c.signal})
|
||||
.then(value=>{if(!c.signal.aborted)setLast(value.items[0]?.order??null);})
|
||||
.catch(e=>{if(!c.signal.aborted)setError(e.message);})
|
||||
.finally(()=>{if(!c.signal.aborted)setBusy(false);});
|
||||
return()=>c.abort();
|
||||
},[request,retry]);
|
||||
return <div className="entry-last-order">
|
||||
<button type="button" className="button" disabled={disabled||busy||last===null} onClick={()=>{if(last!==null)onSelect(last);}}>Letzten Auftrag kopieren</button>
|
||||
{busy?<p className="form-hint" role="status">Letzter Erfassungsvorgang wird geladen …</p>:error?<p className="alert error" role="alert">{error} <button type="button" className="text-button" disabled={disabled} onClick={()=>setRetry(v=>v+1)}>Letzten Auftrag erneut laden</button></p>:<p className="form-hint">{last===null?'Noch kein eigener Erfassungsvorgang in dieser Niederlassung vorhanden.':`Zuletzt von Ihnen in NG erfasst: Auftrag ${last}. Die Kopie wird vor dem Speichern neu geprüft.`}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {request} from './api';
|
||||
import {MasterDocumentsDialog} from './MasterDocuments';
|
||||
|
||||
type Context={enabled:boolean;customerId?:number};
|
||||
export function EntryPayerDocuments({payerId,headquartersId,disabled,onOpenChange}:{payerId:number;headquartersId:number;disabled:boolean;onOpenChange:(open:boolean)=>void}) {
|
||||
const [context,setContext]=useState<Context|null>(null),[error,setError]=useState(''),[retry,setRetry]=useState(0),[open,setOpen]=useState(false);
|
||||
useEffect(()=>{
|
||||
const c=new AbortController();setContext(null);setError('');
|
||||
request<Context>(`/administration/entry-payer-documents?costCenterId=${payerId}&headquartersId=${headquartersId}`,{signal:c.signal})
|
||||
.then(value=>{if(!c.signal.aborted)setContext(value);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});
|
||||
return()=>c.abort();
|
||||
},[payerId,headquartersId,retry]);
|
||||
useEffect(()=>{onOpenChange(open);return()=>onOpenChange(false);},[open,onOpenChange]);
|
||||
return <>
|
||||
{context?.enabled&&context.customerId&&<button type="button" className="button" disabled={disabled} onClick={()=>setOpen(true)}>Dokumente zum Zahler</button>}
|
||||
{error&&<div className="alert error" role="alert">Kundendokumente konnten nicht geprüft werden: {error} <button type="button" className="text-button" disabled={disabled} onClick={()=>setRetry(v=>v+1)}>Dokumentprüfung wiederholen</button></div>}
|
||||
{open&&context?.customerId&&createPortal(<div onChange={e=>e.stopPropagation()} onSubmit={e=>e.stopPropagation()}><MasterDocumentsDialog kind="customers" id={context.customerId} onClose={()=>setOpen(false)}/></div>,document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {useCallback,useEffect,useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {request} from './api';
|
||||
import {Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {Reports} from './Reports';
|
||||
|
||||
type Context={enabled:boolean;customer?:{id:number;label:string;headquartersId:number}};
|
||||
export function EntryPayerReports({payerId,headquartersId,disabled,onOpenChange}:{payerId:number;headquartersId:number;disabled:boolean;onOpenChange:(open:boolean)=>void}) {
|
||||
const [context,setContext]=useState<Context|null>(null),[error,setError]=useState(''),[retry,setRetry]=useState(0),[open,setOpen]=useState(false),[dirty,setDirty]=useState(false);
|
||||
const [discard,discardDialog]=useDiscardConfirmation();
|
||||
const changed=useCallback((value:boolean)=>setDirty(value),[]);
|
||||
useEffect(()=>{
|
||||
const c=new AbortController();setContext(null);setError('');
|
||||
request<Context>(`/administration/entry-payer-reports?costCenterId=${payerId}&headquartersId=${headquartersId}`,{signal:c.signal})
|
||||
.then(value=>{if(!c.signal.aborted)setContext(value);}).catch(e=>{if(!c.signal.aborted)setError(e.message);});
|
||||
return()=>c.abort();
|
||||
},[payerId,headquartersId,retry]);
|
||||
useEffect(()=>{onOpenChange(open);return()=>onOpenChange(false);},[open,onOpenChange]);
|
||||
async function close(){if(!dirty||await discard('Ungespeicherte Berichtsänderungen verwerfen?')){setOpen(false);setDirty(false);}}
|
||||
return <>
|
||||
{context?.enabled&&context.customer&&<button type="button" className="button" disabled={disabled} onClick={()=>setOpen(true)}>Kundenberichte zum Zahler</button>}
|
||||
{error&&<div className="alert error" role="alert">Kundenberichte konnten nicht geprüft werden: {error} <button type="button" className="text-button" disabled={disabled} onClick={()=>setRetry(v=>v+1)}>Erneut prüfen</button></div>}
|
||||
{open&&context?.customer&&createPortal(<div onChange={e=>e.stopPropagation()} onSubmit={e=>e.stopPropagation()}><Modal wide title="Kundenberichte zum Zahler" onClose={()=>void close()}><div className="admin-dialog-body modal-body"><Reports kind="CUSTOMER" refresh={0} initialObject={context.customer} onDirtyChange={changed}/></div><div className="modal-footer"><button type="button" className="button" onClick={()=>void close()}>Zurück zum Auftrag</button></div></Modal>{discardDialog}</div>,document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
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 uiText=useUiText();
|
||||
|
||||
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(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(','))}>{uiText("Alle")}</button><button type="button" className="button" onClick={()=>onChange('')}>{uiText("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>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {Lookup} from './EntryLookup';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
export type CustomerSettingType='PHOTO'|'SERVICE_PRICE';
|
||||
type Values={mode:number|null;minimum:number|null;maximum:number|null;servicePrice:boolean|null};
|
||||
type View={customer:number;label:string;type:CustomerSettingType;revision:string;configured:boolean;values:Values;generalServicePrice:boolean};
|
||||
type Draft={customer:number;type:CustomerSettingType;revision:string;action:'SET'|'REMOVE';values:Values|null};
|
||||
type Preview={fingerprint:string;customer:string;before:string;after:string};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
export function ExtrasCustomerSettings({type,customer:initial,canEdit,onClose,onSaved}:{type:CustomerSettingType;customer:{id:number;label:string}|null;canEdit:boolean;onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [customer,setCustomer]=useState(initial),[view,setView]=useState<View|null>(null),[mode,setMode]=useState(-1),[minimum,setMinimum]=useState(0),[maximum,setMaximum]=useState(1),[enabled,setEnabled]=useState(false);
|
||||
const [preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const saving=useRef(false),pending=useRef<Command|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setView(null);setPreview(null);setError('');if(!customer)return;setBusy(true);request<View>(`/administration/extras/customer-settings/${type}/${customer.id}`).then(v=>{if(active){setView(v);setMode(v.values.mode??-1);setMinimum(v.values.minimum??0);setMaximum(v.values.maximum??1);setEnabled(v.configured);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[customer?.id,type,reload]);
|
||||
const dirty=!!view&&(type==='PHOTO'?(mode!==(view.values.mode??-1)||(mode>=0&&(minimum!==(view.values.minimum??0)||maximum!==(view.values.maximum??1)))):enabled!==view.configured||enabled&&!view.values.servicePrice);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done||!dirty||await discard('Ungespeicherte Kundenvorgabe verwerfen?'))onClose();}
|
||||
async function reloadView(){if(!dirty||await discard('Ungespeicherte Kundenvorgabe verwerfen und neu laden?'))setReload(r=>r+1);}
|
||||
function draft():Draft{if(!view)throw Error('Bitte zuerst einen Kunden laden.');const remove=type==='PHOTO'?mode<0:!enabled;return {customer:view.customer,type,revision:view.revision,action:remove?'REMOVE':'SET',values:remove?null:type==='PHOTO'?{mode,minimum,maximum,servicePrice:null}:{mode:null,minimum:null,maximum:null,servicePrice:true}};}
|
||||
async function check(){setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>('/administration/extras/customer-settings/preview',draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};try{await post('/administration/extras/customer-settings',pending.current);pending.current=null;setUncertain(false);setDone(true);onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){pending.current=null;setPreview(null);}setError((e as Error).message+(retry?' Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen.':''));}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title={type==='PHOTO'?'Beweisfoto-Vorgabe':'Servicepreis-Ausweis'} onClose={()=>void close()} wide><div className="admin-dialog-body">
|
||||
{!initial&&<Lookup label={uiText("Kunde")} endpoint="/administration/extras/customers" value={customer} describe={c=>c.label} disabled={busy||uncertain||done||dirty} onSelect={c=>setCustomer(c)}/>}
|
||||
{view&&<h3>{view.label}</h3>}
|
||||
<p>{type==='PHOTO'?'Diese Vorgaben steuern das klassische Beweisfoto-Verfahren der PHP-Erfassung. Die neue Erfassung verwendet Foto-Workflow-Sets mit eigenen Set- und Stationsvorgaben. Bestehende Aufträge werden nicht rückwirkend angepasst.':'Diese Kundenvorgabe steuert den gesonderten Servicepreis-Ausweis der bestehenden PHP-Rechnungserstellung. Die native Leistungsübersicht ersetzt noch keine vollständige Rechnung.'}</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Kundenvorgabe wird geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Kundenvorgabe gespeichert. Die Übersicht wurde aktualisiert.</p>:view&&<>
|
||||
<fieldset disabled={busy||uncertain||!canEdit} onChange={()=>setPreview(null)}>
|
||||
{type==='PHOTO'?<><Field label="Beweisfoto-Verfahren"><select value={mode} onChange={e=>{const v=Number(e.target.value);setMode(v);if(v===0){setMinimum(0);setMaximum(0);}else if(maximum===0){setMinimum(0);setMaximum(1);}}}><option value={-1}>Keine Kundenvorgabe (entfernen)</option><option value={0}>Fotos gesperrt</option><option value={1}>Zentralenerfassung</option><option value={2}>Kundenerfassung</option><option value={3}>Zentralen- und Kundenerfassung</option></select></Field>{mode>0&&<div className="admin-toolbar"><Field label="Fotos mindestens"><select value={minimum} onChange={e=>setMinimum(Number(e.target.value))}>{[0,1,2,3,4,5].map(n=><option key={n}>{n}</option>)}</select></Field><Field label="Fotos höchstens"><select value={maximum} onChange={e=>setMaximum(Number(e.target.value))}>{[1,2,3,4,5].map(n=><option key={n}>{n}</option>)}</select></Field></div>}{mode>0&&minimum>maximum&&<p role="alert" className="alert error">Die Mindestzahl darf die Höchstzahl nicht überschreiten.</p>}{mode>0&&<p className="form-hint">Mindestens 0: Kann-Fotos. Ab 1: Muss-Fotos. In der klassischen Zentralenerfassung gilt die Kundenvorgabe ab der zweiten Station.</p>}{mode===-1&&<p className="form-hint">{view.configured?'Die kundenspezifische Vorgabe wird entfernt. Allgemeine Foto-Vorgaben gelten anschließend wieder.':'Es ist keine kundenspezifische Vorgabe hinterlegt. Es gelten die allgemeinen Foto-Vorgaben.'}</p>}</>:<><label className="checkbox"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>Servicepreis für diesen Kunden gesondert ausweisen</label>{view.configured&&!view.values.servicePrice&&<p className="form-hint">Die gespeicherte Kundenvorgabe ist derzeit inaktiv (0). Speichern aktiviert sie; Abwählen entfernt sie.</p>}{view.generalServicePrice&&<p className="alert info">Der allgemeine Servicepreis-Ausweis Ihrer Niederlassung ist aktiv. Das Entfernen der Kundenvorgabe schaltet diesen allgemeinen Ausweis nicht aus.</p>}</>}
|
||||
</fieldset>
|
||||
{preview&&<section className="extras-settings-preview"><h3>Geprüfte Änderung</h3><dl><dt>Bisher</dt><dd>{preview.before}</dd><dt>Nach dem Speichern</dt><dd>{preview.after}</dd></dl></section>}
|
||||
{!canEdit&&<p className="form-hint">Zum Ändern benötigen Sie zusätzlich die Rechte für Kundenstamm und Verwaltung.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{customer&&!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reloadView()}>Neu laden</button>}{canEdit&&view&&!done&&(uncertain?<button className="button primary" disabled={busy} onClick={()=>void save()}>Speichervorgang erneut prüfen</button>:preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>Kundenvorgabe speichern</button>:<button className="button primary" disabled={busy||!dirty||(type==='PHOTO'&&minimum>maximum)} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
type Values={begin:string;end:string;weekdayMinutes:string;weekendMinutes:string};
|
||||
type View={revision:string;values:Values;warnings:string[];editable:boolean};
|
||||
type Draft={revision:string;values:Values};
|
||||
type Preview={fingerprint:string;before:string;after:string;scope:string};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
export function ExtrasLeadTime({canEdit,onClose}:{canEdit:boolean;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values|null>(null),[preview,setPreview]=useState<Preview|null>(null);
|
||||
const [error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const saving=useRef(false),pending=useRef<Command|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setView(null);setValues(null);setPreview(null);setError('');setBusy(true);request<View>('/administration/extras/lead-time').then(v=>{if(active){setView(v);setValues(v.values);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[reload]);
|
||||
const dirty=!!view&&JSON.stringify(values)!==JSON.stringify(view.values);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done||!dirty||await discard('Ungespeicherte Vorlaufzeit-Vorgaben verwerfen?'))onClose();}
|
||||
async function reloadView(){if(!dirty||await discard('Ungespeicherte Vorlaufzeit-Vorgaben verwerfen und neu laden?'))setReload(r=>r+1);}
|
||||
function update(key:keyof Values,value:string){setValues(v=>v?{...v,[key]:value}:v);setPreview(null);}
|
||||
function draft():Draft{if(!view||!values)throw Error('Bitte zuerst die Vorgaben laden.');return {revision:view.revision,values};}
|
||||
async function check(){setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>('/administration/extras/lead-time/preview',draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};try{await post('/administration/extras/lead-time',pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){pending.current=null;setPreview(null);}setError((e as Error).message+(retry?' Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen.':''));}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="Globale Vorlaufzeit-Zuschläge" onClose={()=>void close()} wide><div className="admin-dialog-body">
|
||||
<p className="alert info">Diese Vorgaben gelten für alle Niederlassungen.</p><p>Zusätzliche Minuten zu den Vorlaufzeiten: Montag bis Freitag im angegebenen Zeitfenster, Samstag und Sonntag ganztägig. Ein Zeitfenster über Mitternacht ist möglich. 0 Minuten bedeutet keinen Aufschlag.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Vorlaufzeit-Vorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Globale Vorlaufzeit-Vorgaben gespeichert.</p>:view&&values&&<>
|
||||
{view.warnings.length>0&&<div className="alert error" role="alert"><strong>Hinweise zum gespeicherten Bestand</strong><ul>{view.warnings.map(w=><li key={w}>{w}</li>)}</ul></div>}
|
||||
<fieldset disabled={busy||uncertain||!canEdit||!view.editable}><div className="admin-toolbar">
|
||||
<Field label="Startzeit Montag–Freitag"><input value={values.begin} maxLength={5} placeholder="18:00" onChange={e=>update('begin',e.target.value)}/></Field>
|
||||
<Field label="Endzeit Montag–Freitag"><input value={values.end} maxLength={5} placeholder="06:30" onChange={e=>update('end',e.target.value)}/></Field>
|
||||
</div><div className="admin-toolbar">
|
||||
<Field label="Aufschlag Montag–Freitag (Minuten)"><input inputMode="numeric" value={values.weekdayMinutes} maxLength={3} onChange={e=>update('weekdayMinutes',e.target.value)}/></Field>
|
||||
<Field label="Aufschlag Samstag/Sonntag (Minuten)"><input inputMode="numeric" value={values.weekendMinutes} maxLength={3} onChange={e=>update('weekendMinutes',e.target.value)}/></Field>
|
||||
</div><p className="form-hint">Uhrzeiten im Format HH:mm; 24:00 ist als Endzeit möglich. Aufschläge: 0 bis 999 ganze Minuten. Bestehende Abholtermine bleiben unverändert.</p></fieldset>
|
||||
{preview&&<section className="extras-settings-preview"><h3>Geprüfte Änderung</h3><p><strong>{preview.scope}</strong></p><dl><dt>Bisher</dt><dd>{preview.before}</dd><dt>Nach dem Speichern</dt><dd>{preview.after}</dd></dl></section>}
|
||||
{!canEdit&&<p className="form-hint">Zum Ändern benötigen Sie zusätzlich das Verwaltungsrecht.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reloadView()}>Neu laden</button>}{canEdit&&view?.editable&&!done&&(uncertain?<button className="button primary" disabled={busy} onClick={()=>void save()}>Speichervorgang erneut prüfen</button>:preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>Für alle Niederlassungen speichern</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
export type PortalSettingType='MASK'|'LIST';
|
||||
type Values={dispo:boolean|null;cash:boolean|null;manual:boolean|null;map:boolean|null;places:boolean|null;districtCity:string|null};
|
||||
type View={target:number;customer:number;label:string;type:PortalSettingType;revision:string;measures:boolean;values:Values;columns:string[]};
|
||||
type Draft={target:number;type:PortalSettingType;revision:string;values:Values};
|
||||
type Preview={fingerprint:string;customer:string;before:string;after:string};
|
||||
type Command={requestId:string;draft:Draft;fingerprint:string};
|
||||
export function ExtrasPortalSettings({type,target,canEdit,onClose,onSaved}:{type:PortalSettingType;target:number;canEdit:boolean;onClose:()=>void;onSaved:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Values|null>(null),[preview,setPreview]=useState<Preview|null>(null);
|
||||
const [error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const saving=useRef(false),pending=useRef<Command|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setView(null);setValues(null);setPreview(null);setError('');setBusy(true);request<View>(`/administration/extras/portal-settings/${type}/${target}`).then(v=>{if(active){setView(v);setValues(v.values);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[target,type,reload]);
|
||||
const dirty=!!view&&JSON.stringify(values)!==JSON.stringify(view.values);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done||!dirty||await discard('Ungespeicherte Zugangsvorgaben verwerfen?'))onClose();}
|
||||
async function reloadView(){if(!dirty||await discard('Ungespeicherte Zugangsvorgaben verwerfen und neu laden?'))setReload(r=>r+1);}
|
||||
function update(patch:Partial<Values>){setValues(v=>v?{...v,...patch}:v);setPreview(null);}
|
||||
function draft():Draft{if(!view||!values)throw Error('Bitte zuerst die Vorgaben laden.');return {target,type,revision:view.revision,values};}
|
||||
async function check(){setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>('/administration/extras/portal-settings/preview',draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};try{await post('/administration/extras/portal-settings',pending.current);pending.current=null;setUncertain(false);setDone(true);onSaved();}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){pending.current=null;setPreview(null);}setError((e as Error).message+(retry?' Der Speicherausgang ist unklar. Bitte denselben Vorgang erneut prüfen.':''));}finally{saving.current=false;setBusy(false);}}
|
||||
const toggle=(key:'dispo'|'cash'|'manual'|'map'|'places',label:string)=><label className="admin-check"><input type="checkbox" checked={!!values?.[key]} onChange={e=>update({[key]:e.target.checked})}/>{label}</label>;
|
||||
return <><Modal title={type==='MASK'?'Kundenmasken-Erweiterungen':'Kundenzugang-Listenerweiterungen'} onClose={()=>void close()} wide><div className="admin-dialog-body">
|
||||
{view&&<h3>{view.label}</h3>}{error&&<p className="alert error" role="alert">{error}</p>}{busy&&<p role="status">Zugangsvorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">Zugangsvorgaben gespeichert. Die Übersicht wurde aktualisiert.</p>:view&&values&&<>
|
||||
<fieldset disabled={busy||uncertain||!canEdit}>
|
||||
{type==='MASK'?<><label className="admin-check"><input type="checkbox" checked={view.measures} disabled readOnly/>Lademaße (nur lesbar)</label>{toggle('dispo','Dispo-Info anzeigen')}{toggle('cash','Abweichenden Barzahler zulassen')}{toggle('manual','Manuelle Disposition vormerken')}<p className="form-hint">Die manuelle Disposition ist im aktuellen Kundenzugang nicht aktiv. Diese Vorgabe wird gespeichert. Lademaße sind wie in der bisherigen Verwaltungsmaske nur lesbar.</p></>:<>
|
||||
<h3>Für alle Logins dieses Kunden</h3>{toggle('map','Kartenansicht anzeigen')}<Field label="Stadtteile für Ort"><input maxLength={200} placeholder="Keine Stadtteile" value={values.districtCity??''} onChange={e=>update({districtCity:e.target.value})}/></Field><p className="form-hint">Leer deaktiviert die Stadtteilergänzung. Der Wert muss im Stationsort vorkommen, beispielsweise „Berlin“. Die Vorgabe gilt für alle Logins dieses Kunden.</p>
|
||||
<h3>Nur für diesen Login</h3>{toggle('places','Orte in der Auftragsliste anzeigen')}<p className="form-hint">Die zusätzliche Spalte heißt „Stationen“. Weitere Zusatzspalten bleiben erhalten. Aktuell: {view.columns.length?view.columns.join(', '):'keine'}.</p>
|
||||
</>}
|
||||
</fieldset>
|
||||
{preview&&<section className="extras-settings-preview"><h3>Geprüfte Änderung</h3><dl><dt>Bisher</dt><dd>{preview.before}</dd><dt>Nach dem Speichern</dt><dd>{preview.after}</dd></dl></section>}
|
||||
{!canEdit&&<p className="form-hint">Zum Ändern benötigen Sie zusätzlich die Rechte für Kundenstamm und Verwaltung.</p>}
|
||||
</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reloadView()}>Neu laden</button>}{canEdit&&view&&!done&&(uncertain?<button className="button primary" disabled={busy} onClick={()=>void save()}>Speichervorgang erneut prüfen</button>:preview?<button className="button primary" disabled={busy} onClick={()=>void save()}>Zugangsvorgaben speichern</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {post,request} from './api';
|
||||
import {uncertainWrite} from './BusinessForms';
|
||||
export type RoutingSettingType='PZM'|'BWV';
|
||||
type Change={label:string;before:string;after:string};
|
||||
type Preview={fingerprint:string;scope:string;changes:Change[]};
|
||||
type Definition={key:string;label:string;unit:string;optional:boolean;choices:{value:string;label:string}[]};
|
||||
type View={revision:string;type:RoutingSettingType;scope:string;fields:Definition[];values:Record<string,string>;warnings:string[];editable:boolean};
|
||||
export function RoutingChanges({changes}:{changes:Change[]}){return <div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Vorgabe</th><th>Bisher</th><th>Nach dem Speichern</th></tr></thead><tbody>{changes.map(c=><tr key={c.label}><td>{c.label}</td><td>{c.before}</td><td>{c.after}</td></tr>)}</tbody></table>{!changes.length&&<p>Keine Änderungen.</p>}</div>;}
|
||||
export function ExtrasRoutingSettings({type,canEdit,onClose}:{type:RoutingSettingType;canEdit:boolean;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<View|null>(null),[values,setValues]=useState<Record<string,string>>({}),[preview,setPreview]=useState<Preview|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const saving=useRef(false),pending=useRef<{requestId:string;draft:{type:RoutingSettingType;revision:string;values:Record<string,string>};fingerprint:string}|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setView(null);setPreview(null);setError('');setBusy(true);request<View>(`/administration/extras/routing-settings/${type}`).then(v=>{if(active){setView(v);setValues(v.values);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[type,reload]);
|
||||
const dirty=!!view&&JSON.stringify(values)!==JSON.stringify(view.values);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done||!dirty||await discard('Ungespeicherte Tarifvorgaben verwerfen?'))onClose();}
|
||||
async function reloadView(){if(!dirty||await discard('Ungespeicherte Tarifvorgaben verwerfen und neu laden?'))setReload(r=>r+1);}
|
||||
function draft(){if(!view)throw Error('Bitte zuerst die Vorgaben laden.');return {type,revision:view.revision,values};}
|
||||
async function check(){setBusy(true);setError('');setPreview(null);try{setPreview(await post<Preview>('/administration/extras/routing-settings/preview',draft()));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(){if(!preview||saving.current)return;saving.current=true;setBusy(true);setError('');pending.current??={requestId:crypto.randomUUID(),draft:draft(),fingerprint:preview.fingerprint};try{await post('/administration/extras/routing-settings',pending.current);pending.current=null;setUncertain(false);setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry);if(!retry){pending.current=null;setPreview(null);}setError((e as Error).message+(retry?' Bitte denselben Speichervorgang erneut prüfen.':''));}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title={type==='PZM'?'PZM-Vorgaben':'BWV-Einstellungen'} wide onClose={()=>void close()}><div className="admin-dialog-body">
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p role="status">Vorgaben werden geprüft …</p>}
|
||||
{done?<p className="admin-success" role="status">{type}-Vorgaben gespeichert.</p>:view&&<><p className="alert info">{view.scope}. Vorgaben für weitere Berechnungen und Vermittlungen.</p>
|
||||
{type==='PZM'?<p>Last-KM berechnet die kürzeste Anfahrt, Rundtour die Hin- und Rückfahrt für aktivierte Zonen. Die Routenberechnung wählt eine normale, die kürzeste von drei vorgeschlagenen oder die absolut kürzeste Route. Preis-Rundung hebt den PZM-Preis auf volle Euro an; Kundenausnahmen finden Sie in der Zonenansicht.</p>:<p>Fernaufträge werden anhand des durchschnittlichen Abstands zwischen Stationen erkannt (0 deaktiviert die Schwelle). Der Suchradius-Schritt muss größer als 0 sein. Leere Zuschläge und Fahrzeugtarife entfernen die globale Vorgabe; es gilt der jeweilige Tarif oder Standardwert. Niederlassungsvorgaben bleiben erhalten.</p>}
|
||||
{view.warnings.length>0&&<div className="alert info"><strong>Hinweise zum Bestand</strong><ul>{view.warnings.map(w=><li key={w}>{w}</li>)}</ul></div>}
|
||||
<fieldset disabled={busy||uncertain||!canEdit||!view.editable}><div className="routing-settings-grid">{view.fields.map(f=><Field key={f.key} label={f.label+(f.unit?` (${f.unit})`:'')}>{f.choices.length?<select value={values[f.key]} onChange={e=>{setValues({...values,[f.key]:e.target.value});setPreview(null);}}>{!f.choices.some(c=>c.value===values[f.key])&&<option value={values[f.key]}>Ungültiger Bestand: {values[f.key]||'leer'}</option>}{f.choices.map(c=><option key={c.value} value={c.value}>{c.label}</option>)}</select>:<input inputMode={f.unit==='km'?'numeric':'decimal'} value={values[f.key]} maxLength={6} placeholder={f.optional?'Keine Vorgabe':undefined} onChange={e=>{setValues({...values,[f.key]:e.target.value});setPreview(null);}}/>}</Field>)}</div></fieldset>
|
||||
{preview&&<section className="extras-settings-preview"><h3>Geprüfte Änderung</h3><p>{preview.scope}</p><RoutingChanges changes={preview.changes}/></section>}{!canEdit&&<p>Zum Ändern benötigen Sie zusätzlich das Verwaltungsrecht.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reloadView()}>Neu laden</button>}{canEdit&&view?.editable&&!done&&(uncertain?<button className="button primary" disabled={busy} onClick={()=>void save()}>Speichervorgang erneut prüfen</button>:preview?<button className="button primary" disabled={busy||!preview.changes.length} onClick={()=>void save()}>{type==='BWV'?'Für alle Niederlassungen speichern':'Für diese Niederlassung speichern'}</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
|
||||
type Zone={id:string;label:string;approach:boolean};
|
||||
type ZoneView={revision:string;zones:Zone[];roundingExceptions:string[];operations:{token:string;canResume:boolean;changes:Change[]}[]};
|
||||
type ZonePreview={fingerprint:string;changes:Change[]};
|
||||
export function ExtrasPzmZones({canEdit,onClose}:{canEdit:boolean;onClose:()=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const [view,setView]=useState<ZoneView|null>(null),[values,setValues]=useState<Record<string,boolean>>({}),[preview,setPreview]=useState<ZonePreview|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[uncertain,setUncertain]=useState(false),[done,setDone]=useState(false),[reload,setReload]=useState(0);
|
||||
const saving=useRef(false),pending=useRef<{requestId:string;draft:{revision:string;approach:Record<string,boolean>};fingerprint:string}|null>(null);const [discard,discardDialog]=useDiscardConfirmation();
|
||||
useEffect(()=>{let active=true;setView(null);setPreview(null);setError('');setBusy(true);request<ZoneView>('/administration/extras/pzm-zones').then(v=>{if(active){setView(v);setValues(Object.fromEntries(v.zones.map(z=>[z.id,z.approach])));}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[reload]);
|
||||
const dirty=!!view&&view.zones.some(z=>z.approach!==values[z.id]);
|
||||
async function close(){if(busy||uncertain||saving.current)return;if(done||!dirty||await discard('Ungespeicherte PZM-Zonenvorgaben verwerfen?'))onClose();}
|
||||
async function reloadView(){if(!dirty||await discard('Ungespeicherte PZM-Zonenvorgaben verwerfen und neu laden?'))setReload(r=>r+1);}
|
||||
async function check(){if(!view)return;setBusy(true);setError('');setPreview(null);try{setPreview(await post<ZonePreview>('/administration/extras/pzm-zones/preview',{revision:view.revision,approach:values}));}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
async function save(token?:string){if(saving.current||(!token&&(!preview||!view)))return;saving.current=true;setBusy(true);setError('');if(!token&&view&&preview)pending.current??={requestId:crypto.randomUUID(),draft:{revision:view.revision,approach:values},fingerprint:preview.fingerprint};try{await post(token?`/administration/extras/pzm-zones/operations/${token}`:'/administration/extras/pzm-zones',token?{}:pending.current);setUncertain(false);pending.current=null;setDone(true);}catch(e){const retry=uncertainWrite(e);setUncertain(retry&&!token);if(!retry){pending.current=null;setPreview(null);}setError((e as Error).message+(retry&&!token?' Bitte denselben Speichervorgang erneut prüfen.':' Ein bereits angelegter Vorgang bleibt gespeichert und kann über „Neu laden“ wieder angezeigt werden.'));}finally{saving.current=false;setBusy(false);}}
|
||||
return <><Modal title="PZM-Zonen · Anfahrtsberechnung" wide onClose={()=>void close()}><div className="admin-dialog-body"><p>Diese Vorgaben gelten für die eigene Niederlassung. Aktivierte Zonen berechnen die Anfahrt; im Modus Rundtour die Hin- und Rückfahrt. Änderungen werden direkt im PZM-Dienst gespeichert.</p>{error&&<p role="alert" className="alert error">{error}</p>}{busy&&<p role="status">PZM-Zonen werden geprüft …</p>}{done?<p className="admin-success" role="status">PZM-Zonen gespeichert und bestätigt.</p>:view&&<>
|
||||
{view.operations.map(op=><section className="alert info" key={op.token}><strong>Offener PZM-Speichervorgang</strong><RoutingChanges changes={op.changes}/>{op.canResume?<button className="button" disabled={busy||uncertain} onClick={()=>void save(op.token)}>Vorgang fortsetzen</button>:<p>Der ursprüngliche Bearbeiter muss den Vorgang fortsetzen.</p>}</section>)}
|
||||
<fieldset disabled={busy||uncertain||!canEdit||!!view.operations.length}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Zone</th><th>Berechnung der An-/Rückfahrt</th></tr></thead><tbody>{view.zones.map(z=><tr key={z.id}><td>{z.label}</td><td><label><input type="checkbox" checked={values[z.id]} onChange={e=>{setValues({...values,[z.id]:e.target.checked});setPreview(null);}}/> {z.label} berechnen</label></td></tr>)}</tbody></table></div></fieldset>{!view.zones.length&&<p>Keine PZM-Zonen für diese Niederlassung vorhanden.</p>}
|
||||
<h3>Kundenausnahmen bei Preis-Rundung</h3><p>Bei eingeschalteter Aufrundung bleiben diese Kunden ausgenommen:</p>{view.roundingExceptions.length?<ul>{view.roundingExceptions.map(e=><li key={e}>{e}</li>)}</ul>:<p>Keine aktiven Ausnahmen.</p>}
|
||||
{preview&&<section className="extras-settings-preview"><h3>Geprüfte Änderung</h3><RoutingChanges changes={preview.changes}/></section>}{!canEdit&&<p>Zum Ändern benötigen Sie zusätzlich das Verwaltungsrecht.</p>}</>}
|
||||
</div><div className="modal-footer"><button className="button" disabled={busy||uncertain} onClick={()=>void close()}>{done?uiText("Schließen"):'Abbrechen'}</button>{!done&&<button className="button" disabled={busy||uncertain} onClick={()=>void reloadView()}>Neu laden</button>}{canEdit&&view&&!view.operations.length&&!done&&(uncertain?<button className="button primary" disabled={busy} onClick={()=>void save()}>Speichervorgang erneut prüfen</button>:preview?<button className="button primary" disabled={busy||!preview.changes.length} onClick={()=>void save()}>Zonen speichern</button>:<button className="button primary" disabled={busy||!dirty} onClick={()=>void check()}>Änderung prüfen</button>)}</div></Modal>{discardDialog}</>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {RecurringContinuation} from './RecurringContinuation';
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useMemo,useRef,useState,type FormEvent} from 'react';
|
||||
import {Download,LoaderCircle} from 'lucide-react';
|
||||
import {request,post,downloadBusinessFile} from './api';
|
||||
import {Field,Modal} from './FormElements';
|
||||
import {Lookup} from './EntryLookup';
|
||||
import {SavedTourDialog,type SavedTourSelection} from './SavedTourDialog';
|
||||
import {ExtrasCustomerSettings,type CustomerSettingType} from './ExtrasCustomerSettings';
|
||||
import {ExtrasPortalSettings,type PortalSettingType} from './ExtrasPortalSettings';
|
||||
import {ExtrasRoutingSettings,ExtrasPzmZones,type RoutingSettingType} from './ExtrasRoutingSettings';
|
||||
import {ExtrasLeadTime} from './ExtrasLeadTime';
|
||||
import {PzmPriceComparison,type ComparisonSelection} from './PzmPriceComparison';
|
||||
import {RecurringCalendar} from './RecurringCalendar';
|
||||
import {SpecialConditionEditor,type ConditionTarget} from './SpecialConditionEditor';
|
||||
import {OrderWorkbench} from './OrderWorkbench';
|
||||
type Kind={id:string;label:string;dateBasis:string;description:string;customerRequired:boolean;dateRequired:boolean};
|
||||
type Options={today:string;canOpenOrders:boolean;canManageTemplates:boolean;canManageCustomerSettings:boolean;canManageLeadTime:boolean;canOpenCustomer:boolean;canOpenTariffs:boolean;canOpenRelations:boolean;from:string;to:string;kinds:Kind[]};
|
||||
type Query={kind:string;from:string|null;to:string|null;customer:number|null};
|
||||
type Column={key:string;label:string;type:string};
|
||||
type Row=Record<string,string|number|null>;
|
||||
type Result={kind:string;label:string;dateBasis:string;description:string;query:Query;customer:string;columns:Column[];rows:Row[]};
|
||||
const format=(v:Row[string],type='text')=>v===null||v===undefined?'–':type==='money'?new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(v)):type==='number'?Number(v).toLocaleString('de-DE'):String(v);
|
||||
export function ExtrasStatistics({refresh,onDirtyChange,initialCustomer}:{initialCustomer?:{id:number;label:string};refresh:number;onDirtyChange:(dirty:boolean)=>void}){
|
||||
const uiText=useUiText();
|
||||
|
||||
const generation=useRef(0);
|
||||
const [comparison,setComparison]=useState<ComparisonSelection|null>(null);
|
||||
const [routingSetting,setRoutingSetting]=useState<RoutingSettingType|null>(null),[pzmZones,setPzmZones]=useState(false);
|
||||
const [leadTime,setLeadTime]=useState(false);
|
||||
const [portalSetting,setPortalSetting]=useState<{type:PortalSettingType;target:number}|null>(null),[filter,setFilter]=useState('');
|
||||
const [condition,setCondition]=useState<ConditionTarget|null>(null);
|
||||
const [customerSetting,setCustomerSetting]=useState<{type:CustomerSettingType;customer:{id:number;label:string}|null}|null>(null);
|
||||
const [calendar,setCalendar]=useState<{id:number;customer:number;asOf:string}|null>(null),[calendarRefresh,setCalendarRefresh]=useState(0);
|
||||
const [selection,setSelection]=useState<number[]>([]),[templateAction,setTemplateAction]=useState<{action:'RENAME'|'REMOVE';customer:number;rows:SavedTourSelection[]}|null>(null),[order,setOrder]=useState<number|null>(null);
|
||||
useEffect(()=>{onDirtyChange(!!templateAction||order!==null||!!customerSetting||!!condition||!!portalSetting||leadTime||!!routingSetting||pzmZones||!!comparison);return()=>onDirtyChange(false);},[templateAction,order,customerSetting,condition,portalSetting,leadTime,routingSetting,pzmZones,comparison,onDirtyChange]);
|
||||
function manage(action:'RENAME'|'REMOVE',ids:number[]){if(!result?.query.customer)return;setTemplateAction({action,customer:result.query.customer,rows:result.rows.filter(r=>ids.includes(Number(r.id))).map(r=>({id:Number(r.id),name:String(r.name),revision:String(r.revision)}))});}
|
||||
|
||||
const [sort,setSort]=useState<{key:string;descending:boolean}|null>(null);
|
||||
const [options,setOptions]=useState<Options|null>(null),[query,setQuery]=useState<Query|null>(null),[customer,setCustomer]=useState<{id:number;label:string}|null>(null),[result,setResult]=useState<Result|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[page,setPage]=useState(0),[detail,setDetail]=useState<Row|null>(null);
|
||||
useEffect(()=>{generation.current++;let active=true;setBusy(true);setError('');setSelection([]);setResult(null);setDetail(null);setCalendar(null);setOptions(null);request<Options>('/administration/extras/options').then(o=>{if(active){setOptions(o);setQuery({kind:initialCustomer?'RECURRING_ORDERS':o.kinds[0].id,from:initialCustomer?o.today:o.from,to:initialCustomer?o.today:o.to,customer:initialCustomer?.id??null});setCustomer(initialCustomer??null);}}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;generation.current++;};},[refresh,initialCustomer]);
|
||||
const kind=options?.kinds.find(k=>k.id===query?.kind);const current=query?{...query,from:kind?.dateRequired?query.from:null,to:query.kind==='RECURRING_ORDERS'?query.from:kind?.dateRequired?query.to:null,customer:kind?.customerRequired?customer?.id??null:null}:null;const changed=!!result&&JSON.stringify(current)!==JSON.stringify(result.query);
|
||||
async function load(e:FormEvent){e.preventDefault();if(current)await run(current);}
|
||||
async function run(selection:Query){if(selection.kind==='PZM_COMPARISON'){if(selection.customer&&selection.from&&selection.to){setComparison({customer:selection.customer,from:selection.from,to:selection.to});setResult(null);}return;}const version=generation.current;setBusy(true);setError('');try{const data=await post<Result>('/administration/extras/query',selection);if(version===generation.current){setResult(data);setFilter('');setPage(0);setSort(null);setSelection([]);}}catch(e){if(version===generation.current)setError((e as Error).message);}finally{if(version===generation.current)setBusy(false);}}
|
||||
async function csv(){if(!result)return;setBusy(true);setError('');try{await downloadBusinessFile('/administration/extras/csv',result.query,`votian-${result.kind.toLowerCase()}${result.query.from?`-${result.query.from}-${result.query.to}`:""}.csv`);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
|
||||
const rows=useMemo(()=>{const all=[...result?.rows??[]].filter(r=>!filter||Object.entries(r).some(([k,v])=>result?.columns.some(c=>c.key===k&&c.type!=="internal")&&String(v??'').toLocaleLowerCase('de').includes(filter.toLocaleLowerCase('de'))));if(sort)all.sort((a,b)=>{const x=a[sort.key],y=b[sort.key];const order=x==null?(y==null?0:-1):y==null?1:typeof x==='number'&&typeof y==='number'?x-y:String(x).localeCompare(String(y),'de',{numeric:true});return sort.descending?-order:order;});return all;},[result,sort,filter]);
|
||||
function openCondition(row:Row,type:ConditionTarget['type']){setCondition({type,customer:{id:Number(row.customerId),label:[row.customer,row.name,row.name2].filter(Boolean).join(' · ')}});}
|
||||
const settingsType:CustomerSettingType|null=result?.kind==='PHOTO_SETTINGS'?'PHOTO':result?.kind==='INVOICE_SERVICE_SETTINGS'?'SERVICE_PRICE':null;
|
||||
const portalType:PortalSettingType|null=result?.kind==='CUSTOMER_MASK_SETTINGS'?'MASK':result?.kind==='CUSTOMER_LIST_SETTINGS'?'LIST':null;
|
||||
const columns=result?.kind==='FINISHMENT'?result.columns.slice(0,5):result?.columns.filter(c=>c.type!=="internal")??[];
|
||||
return <><h2>Kessel Buntes</h2><p>Sonderauswertungen sowie Kunden-, PZM- und BWV-Vorgaben.</p>{error&&<p className="alert error" role="alert">{error}</p>}{options&&query&&<form onSubmit={load}><fieldset disabled={busy}><div className="admin-toolbar"><Field label="Auswertung"><select value={query.kind} onChange={e=>setQuery({...query,kind:e.target.value,...(e.target.value==='RECURRING_ORDERS'?{from:options.today,to:options.today}:{})})}>{options.kinds.map(k=><option key={k.id} value={k.id}>{k.label}</option>)}</select></Field>{kind?.dateRequired&&<><Field label={query.kind==='RECURRING_ORDERS'?'Stichtag':`${kind.dateBasis} von`}><input type="date" required value={query.from??''} onChange={e=>setQuery({...query,from:e.target.value})}/></Field>{query.kind!=='RECURRING_ORDERS'&&<Field label="Bis"><input type="date" required min={query.from??undefined} value={query.to??''} onChange={e=>setQuery({...query,to:e.target.value})}/></Field>}</>}</div>{kind?.customerRequired&&<Lookup label={uiText("Kunde")} endpoint="/administration/extras/customers" value={customer} describe={c=>c.label} onSelect={setCustomer} disabled={busy}/>}<p className="form-hint">{kind?.description}</p><button className="button primary" disabled={!!kind?.customerRequired&&!customer}>{uiText("Anzeigen")}</button></fieldset></form>}{busy&&<p role="status"><LoaderCircle size={17} className="spin"/> Auswertung wird geladen …</p>}
|
||||
{query?.kind==='RECURRING_ORDERS'&&<RecurringContinuation refresh={refresh+calendarRefresh} onOpen={setOrder}/>}
|
||||
{result&&<section className="extras-result"><div className="admin-title"><div><h3>{result.label}</h3><p>{result.customer} · {result.query.from?`${result.dateBasis}: ${result.query.from}${result.kind==='RECURRING_ORDERS'?'':` bis ${result.query.to}`}`:result.dateBasis}</p></div><button className="button" disabled={busy} onClick={()=>void csv()}><Download size={16}/>CSV herunterladen</button></div>{changed&&<p className="alert info">Die Ergebnisse gehören noch zur vorherigen Auswahl. „Anzeigen“ übernimmt die geänderten Filter.</p>}{result.kind==='TEMPLATE_ORDERS'&&<button className="button" disabled={busy} onClick={()=>{setQuery(q=>q?{...q,kind:'CUSTOMER_TEMPLATES',customer:null}:q);void run({kind:'CUSTOMER_TEMPLATES',from:null,to:null,customer:null});}}>Zur Kunden-Konservenübersicht</button>}{result.kind==='TEMPLATE_ORDERS'&&options?.canManageTemplates&&<div className="admin-row-actions"><button className="button danger-quiet" disabled={busy||!selection.length} onClick={()=>manage('REMOVE',selection)}>{selection.length} Konserven entfernen …</button><button className="button" disabled={busy||!selection.length} onClick={()=>setSelection([])}>Auswahl aufheben</button><span>Höchstens 200 Konserven je Vorgang. Auswahl bleibt beim Seitenwechsel erhalten.</span></div>}{settingsType&&options?.canManageCustomerSettings&&<button className="button" disabled={busy} onClick={()=>setCustomerSetting({type:settingsType,customer:null})}>Kundenvorgabe ergänzen</button>}<p className="form-hint">{result.rows.length.toLocaleString('de-DE')} Ergebniszeilen · {result.description}</p><>{(result.kind==='PZM_SETTINGS'||result.kind==='BWV_SETTINGS')&&<div className="admin-row-actions"><button className="button" disabled={busy} onClick={()=>setRoutingSetting(result.kind==='PZM_SETTINGS'?'PZM':'BWV')}>Vorgaben {options?.canManageLeadTime?'bearbeiten':'ansehen'}</button>{result.kind==='PZM_SETTINGS'&&<button className="button" disabled={busy} onClick={()=>setPzmZones(true)}>PZM-Zonen und Rundungsausnahmen</button>}</div>}{result.kind==='MAIL_SETTINGS'&&<button className="button" disabled={busy} onClick={()=>setLeadTime(true)}>Vorlaufzeit-Zuschläge {options?.canManageLeadTime?'bearbeiten':'ansehen'}</button>}{(portalType||result.kind==='MAIL_SETTINGS')&&<Field label={portalType?"Kunde, Kostenstelle oder Login filtern":"Mail-Verteiler filtern"}><input type="search" value={filter} onChange={e=>{setFilter(e.target.value);setPage(0);}}/></Field>}{filter&&<p>{rows.length.toLocaleString('de-DE')} passende Einträge. Der CSV-Export enthält weiterhin die gesamte Übersicht.</p>}</><div className="admin-table-wrap"><table className="admin-table"><thead><tr>{result.kind==='TEMPLATE_ORDERS'&&options?.canManageTemplates&&<th><input type="checkbox" aria-label="Konserven dieser Seite auswählen" disabled={busy||!rows.length} checked={rows.slice(page*50,(page+1)*50).length>0&&rows.slice(page*50,(page+1)*50).every(r=>selection.includes(Number(r.id)))} onChange={e=>{const ids=rows.slice(page*50,(page+1)*50).map(r=>Number(r.id));setSelection(old=>e.target.checked?[...new Set([...old,...ids])].slice(0,200):old.filter(id=>!ids.includes(id)));}}/></th>}<th>{uiText("Nr.")}</th>{columns.map(c=><th key={c.key} aria-sort={sort?.key===c.key?(sort.descending?'descending':'ascending'):'none'}><button type="button" className="extras-sort" onClick={()=>{setSort({key:c.key,descending:sort?.key===c.key&&!sort.descending});setPage(0);}}>{c.label}{sort?.key===c.key?(sort.descending?' ↓':' ↑'):''}</button></th>)}{result.kind==='FINISHMENT'&&<th>Stationen</th>}{((result.kind==='TEMPLATE_ORDERS'&&options?.canOpenOrders)||result.kind==='RECURRING_ORDERS'||!!settingsType||!!portalType||(result.kind==='GETNOW'&&options?.canOpenOrders)||(result.kind==='SPECIAL_CONDITIONS'&&options?.canOpenCustomer))&&<th>Aktionen</th>}</tr></thead><tbody>{rows.slice(page*50,(page+1)*50).map((r,i)=><tr key={page*50+i}>{result.kind==='TEMPLATE_ORDERS'&&options?.canManageTemplates&&<td><input type="checkbox" aria-label={`Konserve ${r.id} auswählen`} disabled={busy||(!selection.includes(Number(r.id))&&selection.length>=200)} checked={selection.includes(Number(r.id))} onChange={e=>setSelection(old=>e.target.checked?[...old,Number(r.id)]:old.filter(id=>id!==Number(r.id)))}/></td>}<td>{page*50+i+1}</td>{columns.map(c=><td key={c.key}>{result.kind==='SPECIAL_CONDITIONS'&&c.type==='tariff'&&r[c.key]&&options?.canOpenTariffs?<button className="button" disabled={busy} aria-label={`${c.label} für ${r.customer} öffnen`} onClick={()=>openCondition(r,c.key as ConditionTarget['type'])}>Ansehen</button>:result.kind==='SPECIAL_CONDITIONS'&&c.key==='commission'&&r.commission&&options?.canOpenRelations?<button className="button" disabled={busy} aria-label={`Kurierprovision für ${r.customer} öffnen`} onClick={()=>openCondition(r,'commission')}>{r.commission}</button>:result.kind==='CUSTOMER_TEMPLATES'&&c.key==='count'?<button className="button" disabled={busy} aria-label={`Konserven von $Line truncated
|
||||
{detail&&<Modal title={`Stationen · Auftrag ${detail.id}`} wide onClose={()=>setDetail(null)}><div className="admin-dialog-body"><p>Bestellzeit: {format(detail.ordered)}. Die Dauer wird ab diesem Zeitpunkt berechnet.</p><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Station</th><th>{uiText("PLZ")}</th><th>{uiText("Erledigung")}</th><th>Zeit seit Bestellung</th><th>Kommission</th></tr></thead><tbody>{Array.from({length:20},(_,i)=>i+1).filter(n=>['postal','finished','reference','elapsed'].some(k=>detail[k+n]!==null&&detail[k+n]!==undefined&&detail[k+n]!=='' )).map(n=><tr key={n}><td>{n===1?uiText("Abholung"):`Zustellung ${n-1}`}</td><td>{format(detail['postal'+n])}</td><td>{format(detail['finished'+n])}</td><td>{format(detail['elapsed'+n])}</td><td>{format(detail['reference'+n])}</td></tr>)}</tbody></table></div><p className="form-hint">Wie in der alten Erledigungsstatistik werden die Positionen 1 bis 20 ausgewertet.</p></div><div className="modal-footer"><button className="button" onClick={()=>setDetail(null)}>{uiText("Schließen")}</button></div></Modal>}
|
||||
{comparison&&<PzmPriceComparison selection={comparison} onClose={()=>setComparison(null)}/>}
|
||||
{condition&&<SpecialConditionEditor target={condition} onClose={()=>{setCondition(null);if(result)void run(result.query);}}/>}
|
||||
{routingSetting&&<ExtrasRoutingSettings type={routingSetting} canEdit={!!options?.canManageLeadTime} onClose={()=>{setRoutingSetting(null);if(result)void run(result.query);}}/>}
|
||||
{pzmZones&&<ExtrasPzmZones canEdit={!!options?.canManageLeadTime} onClose={()=>setPzmZones(false)}/>}
|
||||
{leadTime&&<ExtrasLeadTime canEdit={!!options?.canManageLeadTime} onClose={()=>setLeadTime(false)}/>}
|
||||
{portalSetting&&<ExtrasPortalSettings {...portalSetting} canEdit={!!options?.canManageCustomerSettings} onClose={()=>setPortalSetting(null)} onSaved={()=>{if(result)void run(result.query);}}/>}
|
||||
{customerSetting&&<ExtrasCustomerSettings {...customerSetting} canEdit={!!options?.canManageCustomerSettings} onClose={()=>setCustomerSetting(null)} onSaved={()=>{if(result)void run(result.query);}}/>}
|
||||
{templateAction&&<SavedTourDialog {...templateAction} selection={templateAction.rows} onClose={()=>setTemplateAction(null)} onSaved={()=>{if(result)void run(result.query);}}/>}
|
||||
{calendar&&<RecurringCalendar {...calendar} refresh={calendarRefresh} onClose={()=>setCalendar(null)} onOpen={setOrder}/>}
|
||||
{order!==null&&<OrderWorkbench id={order} onClose={()=>setOrder(null)} onSaved={()=>{setCalendarRefresh(v=>v+1);if(result)void run(result.query);}}/>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useUiText} from './UiLanguage';
|
||||
import {useEffect,useState,type FormEvent} from 'react';
|
||||
import {Field,Modal,useDiscardConfirmation} from './FormElements';
|
||||
import {request,post,put,ApiError} from './api';
|
||||
@@ -5,24 +6,28 @@ type Filter={id:number;type:number;code:string;name:string;status:number;sort:nu
|
||||
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 uiText=useUiText();
|
||||
|
||||
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}/>}</>;
|
||||
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">{uiText("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>{uiText("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?uiText("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)}>{uiText("Bearbeiten")}</button><button className="button danger" onClick={()=>setDeleting(f)}>{uiText("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 uiText=useUiText();
|
||||
|
||||
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}</>;
|
||||
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={uiText("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':uiText("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>;
|
||||
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 anschließend bereinigt. Bei einer Unterbrechung können Sie denselben Vorgang in der Liste fortsetzen. Das Kürzel bleibt bis zum Abschluss 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>;
|
||||
}
|
||||
Loaded 100 of 1165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user