Erweitere Workflows und Statusmails und bereinige Stadtbote-Tasks

Übernehme die PHP-Fachanpassungen für Foto-Workflows, Mail-Empfänger, Fahrzeugdaten, Nachbearbeitung und PZM-Festpreise.

Beschränke Cron-Funktionen auf Stadtbote, verbessere Java-Typsicherheit und Nullbehandlung und aktualisiere Tests und Dokumentation.

Erhöhe die Backend-Version auf 0.2.1 und passe Maven-Ressourcen sowie VS-Code-Projekteinstellungen an.
This commit is contained in:
Sven committed 2026-11-10 11:32:12 +01:00
1 parent 9de2358480
commit 33aa4b3682
476 files changed
+2726 -2899

No files matched your search

+2 -1
View File
@@ -1,10 +1,11 @@
import {useEffect,useState} from "react";
import {request} from "./api";
import {OrderStatusMailSettings} from "./OrderStatusMailSettings";
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>;
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&&<><OrderStatusMailSettings order={order} onSaved={()=>setReload(n=>n+1)}/><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>;
}
+9 -23
View File
@@ -1,27 +1,13 @@
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[]};
import {request} from './api';
import {Modal} from './FormElements';
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};
type View={label:string;url:string;expiresAt:number;operations:Operation[]};
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}</>;
const [data,setData]=useState<View|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[saved,setSaved]=useState(false),[reload,setReload]=useState(0);
const frame=useRef<HTMLIFrameElement>(null);
useEffect(()=>{const abort=new AbortController();setBusy(true);setData(null);setError('');setSaved(false);request<View>(`/master/customers/${customer}/workflow-management`,{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();},[customer,reload]);
useEffect(()=>{if(!data?.url)return;const origin=new URL(data.url).origin;const receive=(event:MessageEvent)=>{if(event.origin===origin&&event.source===frame.current?.contentWindow&&event.data?.type==='workflow-verwaltung:gespeichert')setSaved(true);};window.addEventListener('message',receive);return()=>window.removeEventListener('message',receive);},[data?.url]);
async function check(operation:Operation){setBusy(true);setError('');try{await request(`/master/customers/${customer}/photo-settings/${operation.requestId}/check`,{method:'POST'});setReload(v=>v+1);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
return <Modal title={`Foto-Workflow-Verwaltung${data?' · '+data.label:''}`} wide onClose={onClose}><div className="admin-dialog-body">{busy&&<p role="status">Workflow-Verwaltung wird geladen …</p>}{error&&<p className="alert error" role="alert">{error}</p>}{saved&&<p className="alert info" role="status">Die Workflow-Verwaltung hat das Speichern bestätigt.</p>}{data?.operations.map(op=><p key={op.requestId} className="alert">{op.message} {op.canCheck&&<button className="button" disabled={busy} onClick={()=>void check(op)}>Bisherigen Vorgang prüfen</button>}</p>)}{data?.url&&<><p>Foto-Sets und Workflows werden direkt in der Workflow-Verwaltung gespeichert.</p><iframe ref={frame} title="Foto-Workflow-Verwaltung" src={data.url} referrerPolicy="no-referrer" style={{width:'100%',height:'65vh',border:0}}/><p><a className="button" href={data.url} target="_blank" rel="noopener noreferrer">In neuem Tab öffnen</a> <small>Link gültig bis {new Date(data.expiresAt*1000).toLocaleTimeString('de-DE')}.</small></p></>}</div><div className="modal-footer"><button className="button" disabled={busy} onClick={()=>setReload(v=>v+1)}>Neu laden</button><button className="button primary" onClick={onClose}>Schließen</button></div></Modal>;
}
+16 -12
View File
@@ -10,7 +10,7 @@ import {OrderAddressInput} from "./OrderAddressInput";
import type { StopType } from "./types";
import {OrderRouteMap,type RouteView} from "./OrderRouteMap";
import {OrderPostalQuote} from "./OrderPostalQuote";
import { type PhotoOptions, type PhotoSet, photoFlows, defaultPhoto, photoLabel } from "./PhotoSets";
import { type PhotoOptions, type ClassicPhotos, type PhotoSet, photoFlows, defaultPhoto, photoLabel } from "./PhotoSets";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { ArrowDown, ArrowUp, Check, LoaderCircle, Plus, X } from "lucide-react";
import { ApiError, request as apiRequest } from "./api";
@@ -27,7 +27,7 @@ type Mode = "IMMEDIATE" | "RESERVATION" | "RECURRING" | "RETROSPECTIVE";
type Option = { id: number; label: string };
type CostCenter = { id: number; customerId: number; customerNumber: string; customerName: string; name: string; defaultPayerId: number | null; fixedDiscount: number; taxId: number | null; headquartersId?:number };
type Vehicle = { id: number; courierId: number; sid: string; name: string; vehicleTypeId: number; plate: string };
type Station = { costCenterId: number | null; type: StopType; name: string; street: string; houseNumber: string; postalCode: string; city: string; country: string; contact: string; phone: string; note: string; reference: string; photoWorkflow: number | null; billingPostalId: number | null; attachments: OrderAttachment[]; trackingEmail?: string | null };
type Station = { costCenterId: number | null; type: StopType; name: string; street: string; houseNumber: string; postalCode: string; city: string; country: string; contact: string; phone: string; note: string; reference: string; photoWorkflow: number | null; classicPhoto?:boolean; billingPostalId: number | null; attachments: OrderAttachment[]; trackingEmail?: string | null };
type Address = Omit<Station, "type" | "reference"> & { id: number };
type EditorStation = Station & { key: string };
type Options = { vehicleTypes: Option[]; taxes: (Option & { rate: number })[]; rhythms: Option[]; editCourierPrice: boolean; overrideCourierLimit: boolean; euCountries?: string[]; limits?:{maxStations:number;maxStationNoteLength:number}; manualDispatchDefault?:boolean; invoiceOnlyTextEnabled?:boolean; fixedDiscountVisible?:boolean; servicePriceVisible?:boolean; entryModes?: {copyAccounting:boolean;laterAccounting:boolean} };
@@ -42,8 +42,8 @@ const euro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currenc
const vehicleLabel = (value: Vehicle) => `${value.sid} · ${value.name}${value.plate ? ` · ${value.plate}` : ""}`;
const newStop = (type: Station["type"]): EditorStation => ({ key: crypto.randomUUID(), costCenterId: null, type, name: "", street: "", houseNumber: "", postalCode: "", city: "", country: "DE", contact: "", phone: "", note: "", reference: "", photoWorkflow: null, billingPostalId: null, attachments: [] });
function StopEditor({ stop, index, count, update, move, remove, disabled, photos, trackingEnabled, maxNoteLength, uploadUrl, mapUrl, onMapOpenChange, onUploadOpenChange, onAddressBusy }: {
stop: EditorStation; index: number; count: number; update: (patch: Partial<EditorStation>) => void; move: (delta: number) => void; remove: () => void; disabled: boolean; maxNoteLength:number; photos: PhotoSet | null; trackingEnabled:boolean; uploadUrl: string; mapUrl: string; onMapOpenChange: (open: boolean) => void; onUploadOpenChange: (open: boolean) => void; onAddressBusy: (busy: boolean) => void;
function StopEditor({ stop, index, count, update, move, remove, disabled, photos, classicPhotos, trackingEnabled, maxNoteLength, uploadUrl, mapUrl, onMapOpenChange, onUploadOpenChange, onAddressBusy }: {
stop: EditorStation; index: number; count: number; update: (patch: Partial<EditorStation>) => void; move: (delta: number) => void; remove: () => void; disabled: boolean; maxNoteLength:number; photos: PhotoSet | null; classicPhotos:ClassicPhotos|null; trackingEnabled:boolean; uploadUrl: string; mapUrl: string; onMapOpenChange: (open: boolean) => void; onUploadOpenChange: (open: boolean) => void; onAddressBusy: (busy: boolean) => void;
}) {
const uiText=useUiText();
@@ -91,6 +91,7 @@ function StopEditor({ stop, index, count, update, move, remove, disabled, photos
<Field label={uiText("Telefon")}><input type="tel" maxLength={20} value={stop.phone} onChange={e => update({ phone: e.target.value })} disabled={disabled} /></Field>
<Field label="Stationsreferenz"><input data-entry-key="reference" maxLength={30} value={stop.reference} onChange={e => update({ reference: e.target.value })} disabled={disabled} /></Field>
{(trackingEnabled||!!stop.trackingEmail)&&<Field label="Tracking-E-Mail"><input type="email" maxLength={60} value={stop.trackingEmail??""} disabled={disabled} onChange={e=>update({trackingEmail:e.target.value||null})}/>{!trackingEnabled&&!!stop.trackingEmail&&<span className="form-hint">Für den gewählten Zahler nicht freigegeben. Bitte den Eintrag entfernen oder den Zahler prüfen.</span>}</Field>}
{classicPhotos&&<label className="check-field"><input type="checkbox" checked={!classicPhotos.disabled&&(!!stop.classicPhoto||classicPhotos.required&&index>0)} disabled={disabled||classicPhotos.disabled||classicPhotos.required&&index>0} onChange={e=>update({classicPhoto:e.target.checked?true:undefined})}/>{classicPhotos.disabled?"Fotos für diesen Kunden gesperrt":`Fotos anfordern (${classicPhotos.minimum}–${classicPhotos.maximum})`}{classicPhotos.required&&index>0?" · Kundenvorgabe":""}</label>}
{(photos||stop.photoWorkflow!=null) && <Field label="Fotoworkflow"><select value={stop.photoWorkflow ?? ""} disabled={disabled} onChange={e => update({ photoWorkflow: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">Set-Vorgabe · {defaultPhoto(photos?photoFlows(photos,stop.type):[],index,count)?.name ?? "Keine Stationsvorgabe"}</option>
{stop.photoWorkflow!=null&&!(photos?photoFlows(photos,stop.type):[]).some(f=>f.number===stop.photoWorkflow)&&<option value={stop.photoWorkflow}>Gespeicherter Workflow {stop.photoWorkflow} · Auswahl prüfen</option>}
@@ -181,10 +182,10 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
const changed=previousPhotoPayer.current!==(payer?.id??null), copied=copiedPhotoPayer.current===payer?.id;
previousPhotoPayer.current=payer?.id??null;copiedPhotoPayer.current=null;
setPhotos(null); setPhotoError(""); setQuote(null);
if(changed&&!copied){setPhotoSet(null);setStops(items => items.map(stop => ({ ...stop, photoWorkflow: null })));}
if(changed&&!copied){setPhotoSet(null);setStops(items => items.map(stop => ({ ...stop, photoWorkflow: null, classicPhoto: undefined })));}
if (!payer) return;
request<PhotoOptions>(`/orders/entry/cost-centers/${payer.id}/photo-workflows`)
.then(value => { if (active) setPhotos(value); })
.then(value => { if (active) {setPhotos(value);if(value.classic){setPhotoSet(null);setStops(items=>items.map(stop=>({...stop,photoWorkflow:null})));}else setStops(items=>items.map(stop=>({...stop,classicPhoto:undefined})));} })
.catch((e: Error) => { if (active) setPhotoError(e.message); });
return () => { active = false; };
}, [payer?.id, photoRetry]);
@@ -199,12 +200,13 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
const copiedTax=copiedTaxPayer.current===payer.id;copiedTaxPayer.current=null;
if (!copiedTax && payer.taxId) setTaxId(String(payer.taxId));
Promise.all([request<CustomerDefaults>(`/orders/entry/cost-centers/${payer.id}/defaults`), request<ServiceOption[]>(`/orders/entry/cost-centers/${payer.id}/services`)])
.then(([value, items]) => { if (active) { setDefaults(value); setServices(items); if(discountPayer.current!==payer.id){setApplyFixedDiscount(!!value.fixedDiscountDefault);discountPayer.current=payer.id;} if (loadedServices.current) { setServiceItems(loadedServices.current); loadedServices.current = null; } if (value.calculationRequired) setPriceMode("SERVICES"); else if(templatePriceActive.current && templatePayer.current!==payer.id && value.automaticPriceMode==="ZONE"){setPriceMode("AUTO");templatePriceActive.current=false;} } })
.then(([value, items]) => { if (active) { setDefaults(value); setServices(items); if(discountPayer.current!==payer.id){setApplyFixedDiscount(!!value.fixedDiscountDefault);discountPayer.current=payer.id;} if (loadedServices.current) { setServiceItems(loadedServices.current); loadedServices.current = null; } if (value.calculationRequired) setPriceMode("SERVICES"); else if(templatePriceActive.current && templatePayer.current!==payer.id && value.automaticPriceMode==="ZONE"){setPriceMode("AUTO");setTemplatePriceChanged(true);templatePriceActive.current=false;} } })
.catch((e: Error) => { if (active) setCustomerError(e.message); });
return () => { active = false; };
}, [payer?.id, photoRetry]);
const invalidate = () => { setDirty(true); setQuote(null); setError(""); setOptimization(null);setOptimizationNotice(""); };
const recalculateTemplate = () => {if(templatePriceActive.current && defaults?.automaticPriceMode==="ZONE"){setPriceMode("AUTO");templatePriceActive.current=false;}};
const [templatePriceChanged,setTemplatePriceChanged]=useState(false);
const recalculateTemplate = () => {if(templatePriceActive.current && defaults?.automaticPriceMode==="ZONE"){setPriceMode("AUTO");setTemplatePriceChanged(true);templatePriceActive.current=false;}};
async function useDefaultPayer() {
const id = payer?.defaultPayerId;
if (!id || id === payer?.id || locked || inFlight.current || openUploads.size) return;
@@ -233,6 +235,7 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
const data = await request<Record<string, unknown> & { payer: CostCenter; stops: Station[]; services: ServiceItem[]; courierShares?:{vehicle:Vehicle|null;amount:number|string}[]; entryMode?:Mode; pickupAt?:string; warningAt?:string; recurrence?:{rhythm:number;weekdays:string;endDate?:string}; vehicle?:Vehicle; copyWarning?:string; priceMode?:string; taxId?:number; cashStop?:number|null; photoSet?:number|null; courierFilters?:string[]; articles?: { article: ArticleDraft["article"]; freeArticle?: ArticleDraft["freeArticle"]; station: number; quantity: number; remark: string }[] }>(`/orders/entry/saved-orders/${value.id}?dispatch=${dispatchOverride}&template=${asTemplate}`);
const fields: Record<string, string | number> = {};
for (const [key, v] of Object.entries(data)) if (typeof v === "string" || typeof v === "number") fields[key] = v;
fields.replaceStandardRecipients=data.replaceStandardRecipients===true?1:0;
discountPayer.current=data.payer.id;setApplyFixedDiscount(Number(data.applyFixedDiscount)===1);setHasServicePrice(Number(data.servicePrice)>0);
copiedTaxPayer.current=data.payer.id!==payer?.id?data.payer.id:null;setTaxId(String(data.taxId??data.payer.taxId??options?.taxes.find(t=>t.id===4)?.id??options?.taxes[0]?.id??""));
copiedPhotoPayer.current=data.payer.id!==payer?.id?data.payer.id:null;setPhotoSet(data.photoSet??null);
@@ -242,7 +245,7 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
setStops(loadedStops);
setArticles((data.articles ?? []).map(item => ({ key: crypto.randomUUID(), article: item.article, freeArticle: item.freeArticle, stationKey: loadedStops[item.station - 1]?.key ?? "", quantity: item.quantity, serialNumber: "", remark: item.remark, book: false, from: null, to: null })));
setSustainabilityMode("NONE"); setVehicleType(String(data.vehicleTypeId)); setVehicle(data.vehicle ?? null); setManual(data.manualDispatch===true); setIncomplete(data.incomplete===1); setCancelUnavailable(data.cancelIfUnavailable===1); setMode(data.entryMode ?? "IMMEDIATE"); setPickupAt(data.pickupAt?.slice(0,16) ?? ""); setWarningAt(data.warningAt?.slice(0,16)??""); setRhythm(data.recurrence?.rhythm??1); setWeekdays(data.recurrence?.weekdays??"1111100"); setEndDate(data.recurrence?.endDate??""); setPayment(String(data.payment)); setCashStop(data.payment==="CASH"?(data.cashStop??0):1);
templatePayer.current=data.payer.id;templatePriceActive.current=Number(data.fixedPrice)>0;setPriceMode(data.priceMode??(data.services?.length ? "SERVICES" : Number(data.fixedPrice) > 0 ? "FIXED" : "AUTO")); setOrigin(Number(data.origin??1)); setBwv(data.bwv===true); setOffer(data.offer===true); setInsured(data.goodsValue!=null); setAllCouriers(data.allCouriers===true); setLonghaul(data.longhaul==="ASAP"?"ASAP":"AUTO"); setSelectedFilters(data.courierFilters ?? []); setServiceItems(data.services ?? []);
templatePayer.current=data.payer.id;templatePriceActive.current=data.fixedPriceFromZone===true&&Number(data.fixedPrice)>0;setTemplatePriceChanged(false);setPriceMode(data.priceMode??(data.services?.length ? "SERVICES" : Number(data.fixedPrice) > 0 ? "FIXED" : "AUTO")); setOrigin(Number(data.origin??1)); setBwv(data.bwv===true); setOffer(data.offer===true); setInsured(data.goodsValue!=null); setAllCouriers(data.allCouriers===true); setLonghaul(data.longhaul==="ASAP"?"ASAP":"AUTO"); setSelectedFilters(data.courierFilters ?? []); setServiceItems(data.services ?? []);
setCopyNotice(data.copyWarning ?? (data.vehicle ? "Fahrzeug übernommen. Auftragsart und Ausführungszeit vor dem Speichern prüfen." : ""));
requestId.current = crypto.randomUUID(); invalidate();
} catch (e) { setError((e as Error).message); } finally { inFlight.current = false; setBusy(false); }
@@ -392,7 +395,7 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
<Field label="Gleiche Aufträge anlegen"><select name="copies" disabled={locked}>{Array.from({ length: 10 }, (_, i) => <option key={i + 1} value={i + 1}>{i + 1}</option>)}</select></Field>
</div>
{customerError && <p className="alert error" role="alert">{customerError} <button className="text-button" type="button" onClick={() => setPhotoRetry(v => v + 1)}>Kundenvorgaben erneut laden</button></p>}
{payer&&<OrderStatusMail key={`${payer.id}:${payment}:${formKey}`} payer={payer.id} payment={payment} vehicleType={vehicleType} offer={offer} disabled={locked} initialRecipients={templatePayer.current===payer.id&&draft.payment===payment?String(draft.statusMailRecipients??""):""} onSelection={receiveMail} onEdit={invalidate}/>}
{payer&&<OrderStatusMail key={`${payer.id}:${payment}:${formKey}`} payer={payer.id} payment={payment} vehicleType={vehicleType} offer={offer} disabled={locked} initialRecipients={templatePayer.current===payer.id&&draft.payment===payment?String(draft.statusMailRecipients??""):""} initialReplace={templatePayer.current===payer.id&&draft.payment===payment&&Number(draft.replaceStandardRecipients)===1} onSelection={receiveMail} onEdit={invalidate}/>}
{defaults && <details><summary>Rechnungsmail und Kundenvorgaben</summary><Field label="Rechnungsmail"><input key={payer?.id} type="email" name="invoiceEmail" defaultValue={defaults.invoiceEmail} maxLength={254} disabled={locked} /></Field>
<label className="entry-checkbox"><input key={payer?.id} type="checkbox" name="updateInvoiceEmail" disabled={locked||!!payer?.headquartersId&&payer.headquartersId!==headquarters} />Rechnungsmail im Kundenstamm aktualisieren</label>{payer?.headquartersId&&payer.headquartersId!==headquarters?<p className="form-hint">Der Zahler gehört zu einer anderen Niederlassung. Die Rechnungsmail bitte dort im Kundenstamm pflegen.</p>:null}
<p className="form-hint">{defaults.vatId ? `Umsatzsteuer-ID: ${defaults.vatId}. ` : "Keine Umsatzsteuer-ID hinterlegt. "}{defaults.calculationRequired && "Leistungskalkulation ist für diesen Kunden Pflicht."}</p>
@@ -419,7 +422,7 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
{photoError && <div className="alert error" role="alert">{photoError} <button type="button" className="text-button" disabled={locked} onClick={() => setPhotoRetry(value => value + 1)}>Fotoworkflows erneut laden</button></div>}
{photos?.configured && photos.sets.length === 0 && <p className="form-hint">Für diesen Kunden sind keine Foto-Sets freigegeben.</p>}
{!!photos?.sets.length && <Field label="Foto-Set"><select value={photoSet ?? ""} disabled={locked} onChange={e=>{invalidate();setPhotoSet(e.target.value===""?null:Number(e.target.value));setStops(items=>items.map(stop=>({...stop,photoWorkflow:null})));}}><option value="">Kundenstandard · {(photos.sets.find(s=>s.defaultSet)??photos.sets[0]).name}</option>{photos.sets.map(s=><option key={s.number} value={s.number}>{s.name}</option>)}</select></Field>}
{stops.map((stop, index) => <StopEditor maxNoteLength={options.limits?.maxStationNoteLength??2000} trackingEnabled={!!defaults?.trackingEnabled} key={stop.key} stop={stop} index={index} count={stops.length} disabled={locked} photos={photos?.sets.find(s=>s.number===photoSet)??(photoSet===null?(photos?.sets.find(s=>s.defaultSet)??photos?.sets[0]??null):null)} uploadUrl={uploadUrl} mapUrl={features.mapUrl} onAddressBusy={open => setOpenUploads(keys => { const key=`address:${stop.key}`;if(keys.has(key)===open)return keys;const next=new Set(keys);if(open)next.add(key);else next.delete(key);return next;})} onMapOpenChange={open => setOpenUploads(keys => { const key = `map:${stop.key}`; if (keys.has(key) === open) return keys; const next = new Set(keys); if (open) next.add(key); else next.delete(key); return next; })} onUploadOpenChange={open => setOpenUploads(keys => { if (keys.has(stop.key) === open) return keys; const next = new Set(keys); if (open) next.add(stop.key); else next.delete(stop.key); return next; })} update={values => updateStop(stop.key, values.type ? {...values,photoWorkflow:null}:values)} move={delta => { recalculateTemplate();invalidate(); setStops(items => { const next = [...items]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; return next; }); if (payment === "CASH") { if (cashStop === index + 1) setCashStop(index + delta + 1); else if (cashStop === index + delta + 1) setCashStop(index + 1); } }} remove={() => { recalculateTemplate();invalidate(); setStops(items => items.filter(s => s.key !== stop.key)); setCashStop(value => value > index + 1 ? value - 1 : value === index + 1 ? 0 : value); }} />)}
{stops.map((stop, index) => <StopEditor maxNoteLength={options.limits?.maxStationNoteLength??2000} trackingEnabled={!!defaults?.trackingEnabled} key={stop.key} stop={stop} index={index} count={stops.length} disabled={locked} classicPhotos={photos?.classic??null} photos={photos?.sets.find(s=>s.number===photoSet)??(photoSet===null?(photos?.sets.find(s=>s.defaultSet)??photos?.sets[0]??null):null)} uploadUrl={uploadUrl} mapUrl={features.mapUrl} onAddressBusy={open => setOpenUploads(keys => { const key=`address:${stop.key}`;if(keys.has(key)===open)return keys;const next=new Set(keys);if(open)next.add(key);else next.delete(key);return next;})} onMapOpenChange={open => setOpenUploads(keys => { const key = `map:${stop.key}`; if (keys.has(key) === open) return keys; const next = new Set(keys); if (open) next.add(key); else next.delete(key); return next; })} onUploadOpenChange={open => setOpenUploads(keys => { if (keys.has(stop.key) === open) return keys; const next = new Set(keys); if (open) next.add(stop.key); else next.delete(stop.key); return next; })} update={values => updateStop(stop.key, values.type ? {...values,photoWorkflow:null}:values)} move={delta => { recalculateTemplate();invalidate(); setStops(items => { const next = [...items]; [next[index], next[index + delta]] = [next[index + delta], next[index]]; return next; }); if (payment === "CASH") { if (cashStop === index + 1) setCashStop(index + delta + 1); else if (cashStop === index + delta + 1) setCashStop(index + 1); } }} remove={() => { recalculateTemplate();invalidate(); setStops(items => items.filter(s => s.key !== stop.key)); setCashStop(value => value > index + 1 ? value - 1 : value === index + 1 ? 0 : value); }} />)}
<EntryCustomerNotices assignments={[...(payer?[{id:payer.id,label:"Zahler"}]:[]),...stops.flatMap((stop,index)=>stop.costCenterId?[{id:stop.costCenterId,label:`Station ${index+1}`}]:[])]} disabled={locked}/>
{!!options.euCountries?.length && stops.some(stop => !options.euCountries?.includes(stop.country)) && <p className="alert" role="status">Nicht-EU-Transport: Schriftliche Bestätigung der Versender-Eigenschaft nach der Handlungsanweisung Drittland anfordern. Der neue Auftrag bleibt bis zur Prüfung als unvollständig markiert.</p>}
<label className="entry-checkbox"><input data-entry-key="f" type="checkbox" name="acceptUnverifiedAddresses" disabled={locked} />Ungeprüfte Adressen ausdrücklich übernehmen</label>
@@ -452,8 +455,9 @@ function OrderEntryEditor({ onClose, onCreated, branches, headquarters, onHeadqu
</section>}
<section className="form-section"><EntryArticleFields rows={articles} onChange={values => { invalidate(); setArticles(values); }} stops={stops} payerId={payer?.id} canBook={features.bookStock} disabled={locked} allowBooking={!offer && mode !== "RECURRING"} /></section>
<section className="form-section"><div className="form-section-header"><h3><span>04</span>Preis und Prüfung</h3><button type="button" className="button secondary" disabled={locked||openUploads.size>0} onClick={()=>setPostalQuery(true)}>PLZ-Preisanfrage</button></div>
<div className="form-grid cols-3"><Field label="Preisermittlung"><select value={priceMode} disabled={locked} onChange={e => {templatePriceActive.current=false;setPriceMode(e.target.value);}}><option value="AUTO">Automatisch nach Kundenabrechnungsmodus{defaults?.automaticPriceMode==="ZONE"?" · PZM":defaults?.automaticPriceMode==="TARIFF"?" · PLZ / Gebiet":""}</option><option value="TARIFF">Bestandstarif (PLZ / Gebiet)</option><option value="FIXED">Festpreis</option><option value="KILOMETER">Automatisch nach Kilometern</option><option value="ZONE">Automatisch nach Tarifzonen (PZM)</option><option value="SERVICES">Leistungskalkulation</option></select></Field>
<div className="form-grid cols-3"><Field label="Preisermittlung"><select value={priceMode} disabled={locked} onChange={e => {templatePriceActive.current=false;setTemplatePriceChanged(false);setPriceMode(e.target.value);}}><option value="AUTO">Automatisch nach Kundenabrechnungsmodus{defaults?.automaticPriceMode==="ZONE"?" · PZM":defaults?.automaticPriceMode==="TARIFF"?" · PLZ / Gebiet":""}</option><option value="TARIFF">Bestandstarif (PLZ / Gebiet)</option><option value="FIXED">Festpreis</option><option value="KILOMETER">Automatisch nach Kilometern</option><option value="ZONE">Automatisch nach Tarifzonen (PZM)</option><option value="SERVICES">Leistungskalkulation</option></select></Field>
<Field label="Fernverkehr"><select value={longhaul} disabled={locked} onChange={e => setLonghaul(e.target.value)}><option value="AUTO">Automatisch anhand der Strecke</option><option value="ASAP">ASAP · keinen Fernauftrag anlegen</option></select></Field>
{templatePriceChanged&&priceMode!=="FIXED"&&Number(draft.fixedPrice)>0&&<p className="form-hint">Der übernommene PZM-Preis wird wegen geänderter Angaben neu berechnet. <button type="button" className="text-button" disabled={locked} onClick={()=>{templatePriceActive.current=false;setTemplatePriceChanged(false);setPriceMode("FIXED");invalidate();}}>Bisherigen Festpreis ({Number(draft.fixedPrice).toLocaleString("de-DE",{minimumFractionDigits:2})} €) beibehalten</button></p>}
{priceMode === "FIXED" && <Field label="Festpreis netto, vor Zuschlag (€) *"><input required type="number" name="fixedPrice" defaultValue={draft.fixedPrice ?? ""} min="0.01" max="999999.99" step="0.01" disabled={locked} /></Field>}
<Field label="Maut / Auslagen netto (€)"><input name="toll" defaultValue={draft.toll ?? ""} type="number" min="0" max="999999.99" step="0.01" disabled={locked} /></Field>
{(options.servicePriceVisible||draft.servicePrice!=null&&String(draft.servicePrice)!=="")&&<div><Field label="Servicepreis netto (€)"><input type="number" name="servicePrice" defaultValue={draft.servicePrice ?? ""} min="0" max="999999.99" step="0.01" readOnly={!options.servicePriceVisible} disabled={locked} /></Field>{!options.servicePriceVisible&&<p className="form-hint">Servicepreis aus der Vorlage bleibt erhalten; die Eingabe ist in dieser Niederlassung ausgeblendet.</p>}</div>}
+10 -8
View File
@@ -2,15 +2,15 @@ import {useEffect,useState} from "react";
import {Field} from "./FormElements";
import {useEntryApi} from "./EntryScope";
export type StatusMailSelection={recipients:string;revision:string};
export type StatusMailPlan={automaticRecipients:string[];additionalRecipients:string[];events:string[];warnings:string[]};
export type StatusMailSelection={recipients:string;revision:string;replaceStandardRecipients?:boolean};
export type StatusMailPlan={automaticRecipients:string[];additionalRecipients:string[];events:string[];warnings:string[];replaceStandardRecipients?:boolean};
type Policy={automaticRecipients:string[];events:string[];requestRecipients:string[];additionalAllowed:boolean;warnings:string[];revision:string};
export function OrderStatusMail({payer,payment,vehicleType,offer,disabled,initialRecipients,onSelection,onEdit}:{
payer:number;payment:string;vehicleType:string;offer:boolean;disabled:boolean;initialRecipients:string;
export function OrderStatusMail({payer,payment,vehicleType,offer,disabled,initialRecipients,initialReplace=false,onSelection,onEdit}:{
payer:number;payment:string;vehicleType:string;offer:boolean;disabled:boolean;initialRecipients:string;initialReplace?:boolean;
onSelection:(selection:StatusMailSelection|null)=>void;onEdit:()=>void;
}){
const {request}=useEntryApi();const [loaded,setLoaded]=useState<{key:string;policy:Policy}|null>(null),[error,setError]=useState("");
const [recipients,setRecipients]=useState(initialRecipients),[retry,setRetry]=useState(0);
const [recipients,setRecipients]=useState(initialRecipients),[retry,setRetry]=useState(0),[replace,setReplace]=useState(initialReplace&&!!initialRecipients.trim());
const queryKey=`${payer}:${payment}:${vehicleType}`;
const policy=loaded?.key===queryKey?loaded.policy:null;
useEffect(()=>{let active=true;setLoaded(null);setError("");
@@ -19,15 +19,16 @@ export function OrderStatusMail({payer,payment,vehicleType,offer,disabled,initia
.then(value=>{if(active)setLoaded({key:queryKey,policy:value});}).catch((e:Error)=>{if(active)setError(e.message);});
return()=>{active=false;};
},[payer,payment,vehicleType,queryKey,retry,request]);
useEffect(()=>{onSelection(policy?{recipients:offer?"":recipients,revision:policy.revision}:null);},[policy,recipients,offer,onSelection]);
useEffect(()=>{onSelection(policy?{recipients:offer?"":recipients,revision:policy.revision,replaceStandardRecipients:!offer&&replace&&!!recipients.trim()}:null);},[policy,recipients,replace,offer,onSelection]);
return <details open className="entry-status-mail"><summary>Statusmails</summary>
{error?<p role="alert" className="alert error">{error}</p>:!policy?<p className="form-hint">{vehicleType?"Mailvorgaben werden geladen …":"Für die Mailvorgaben zuerst den Fahrzeugtyp auswählen."}</p>:<>
{offer?<p className="form-hint">Für Angebote werden keine automatischen Statusmails ausgelöst.</p>:<>
<p className="form-hint">{policy.events.length?`Automatische Meldungen bei ${policy.events.join(" / ")}.`:"Für diesen Zahler sind keine automatischen Statusmails eingerichtet."}</p>
{!!policy.automaticRecipients.length&&<p>Standardempfänger: {policy.automaticRecipients.join(", ")}</p>}
{(policy.additionalAllowed||recipients)&&<Field label="Zusätzliche Statusmail-Empfänger"><input value={recipients} maxLength={2000} disabled={disabled} placeholder="name@firma.de, weitere@firma.de" onChange={e=>{setRecipients(e.target.value);onEdit();}}/></Field>}
{(policy.additionalAllowed||recipients)&&<Field label="Zusätzliche Statusmail-Empfänger"><input value={recipients} maxLength={2000} disabled={disabled} placeholder="name@firma.de, weitere@firma.de" onChange={e=>{setRecipients(e.target.value);if(!e.target.value.trim())setReplace(false);onEdit();}}/></Field>}
{!!recipients&&!policy.additionalAllowed&&<p role="alert" className="alert error">Die hinterlegten Ereignisse erlauben keine zusätzlichen Empfänger. Bitte das Feld leeren oder die Kundenvorgaben prüfen lassen.</p>}
{policy.additionalAllowed&&<p className="form-hint">Mehrere Adressen mit Komma oder Semikolon trennen. Sie gelten für jeden neu angelegten Auftrag einschließlich Kopien und Serienterminen. Die Standardempfänger erhalten weiterhin ihre Meldungen.</p>}
{policy.additionalAllowed&&!!recipients.trim()&&<label className="checkbox-label"><input type="checkbox" checked={replace} disabled={disabled} onChange={e=>{setReplace(e.target.checked);onEdit();}}/>Standard-Mailempfänger für diesen Auftrag ersetzen</label>}
{policy.additionalAllowed&&<p className="form-hint">Mehrere Adressen mit Komma oder Semikolon trennen. Sie gelten für jeden neu angelegten Auftrag einschließlich Kopien und Serienterminen. {replace?"Die Standardempfänger werden für diesen Auftrag ersetzt; Standard-Stationsmails werden ebenfalls unterdrückt.":"Die Standardempfänger erhalten weiterhin ihre Meldungen."}</p>}
</>}
{policy.warnings.map(w=><p key={w} className="alert">{w}</p>)}
</>}
@@ -36,6 +37,7 @@ export function OrderStatusMail({payer,payment,vehicleType,offer,disabled,initia
}
export function OrderStatusMailPreview({plan}:{plan:StatusMailPlan}){
return <div aria-label="Statusmail-Vorschau"><p><strong>Statusmails:</strong> {plan.events.length?plan.events.join(" / "):"Keine automatischen Statusmails"}</p>
{plan.replaceStandardRecipients&&<p>Standardempfänger und Standard-Stationsmails werden für diesen Auftrag ersetzt.</p>}
{!!plan.automaticRecipients.length&&<p>Standardempfänger: {plan.automaticRecipients.join(", ")}</p>}
{!!plan.additionalRecipients.length&&<p>Zusätzliche Empfänger: {plan.additionalRecipients.join(", ")}</p>}
{!!plan.additionalRecipients.length&&<p className="form-hint">Beim Speichern werden diese Empfänger für die automatische Verarbeitung vorgemerkt. Das ist noch keine Versandbestätigung.</p>}
+11
View File
@@ -0,0 +1,11 @@
import {useEffect,useState} from 'react';
import {ApiError,request} from './api';
type View={revision:string;recipients:string;replaceStandardRecipients:boolean;canEdit:boolean;reason:string};
type Command={requestId:string;revision:string;recipients:string;replaceStandardRecipients:boolean};
export function OrderStatusMailSettings({order,onSaved}:{order:number;onSaved:()=>void}){
const [data,setData]=useState<View|null>(null),[recipients,setRecipients]=useState(''),[replace,setReplace]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[pending,setPending]=useState<Command|null>(null),[reload,setReload]=useState(0);
function accept(v:View){setData(v);setRecipients(v.recipients);setReplace(v.replaceStandardRecipients);}
useEffect(()=>{const abort=new AbortController();setData(null);request<View>(`/orders/${order}/status-mail-settings`,{signal:abort.signal}).then(v=>{if(!abort.signal.aborted)accept(v);}).catch((e:Error)=>{if(!abort.signal.aborted)setError(e.message);});return()=>abort.abort();},[order,reload]);
async function save(){if(!data)return;const command=pending??{requestId:crypto.randomUUID(),revision:data.revision,recipients,replaceStandardRecipients:replace&&!!recipients.trim()};setBusy(true);setError('');setPending(command);try{accept(await request<View>(`/orders/${order}/status-mail-settings`,{method:'POST',body:JSON.stringify(command)}));setPending(null);onSaved();}catch(e){setError((e as Error).message);if(e instanceof ApiError&&e.status>=400&&e.status<500)setPending(null);}finally{setBusy(false);}}
return <section aria-label="Statusmail-Empfänger bearbeiten"><h4>Empfänger für diesen Auftrag</h4>{error&&<p className="alert error" role="alert">{error}</p>}{data&&<><fieldset disabled={busy||!!pending||!data.canEdit}><label>Zusätzliche Statusmail-Empfänger<input value={recipients} maxLength={2000} placeholder="mail@example.de" onChange={e=>{setRecipients(e.target.value);if(!e.target.value.trim())setReplace(false);}}/></label>{!!recipients.trim()&&<label className="check-field"><input type="checkbox" checked={replace} onChange={e=>setReplace(e.target.checked)}/>Standard-Mailempfänger für diesen Auftrag ersetzen</label>}</fieldset><p className="form-hint">Die Ersetzung gilt für Erfassung, Abholung, Erledigung und Stationsabschlüsse. Bereits verarbeitete Mails werden nicht erneut versendet. Änderungen gelten für noch ausstehende Statusmails.</p>{data.reason&&<p>{data.reason}</p>}{data.canEdit&&<button className="button" disabled={busy||!pending&&recipients===data.recipients&&replace===data.replaceStandardRecipients} onClick={()=>void save()}>{pending?'Speichervorgang erneut prüfen':'Empfänger speichern'}</button>}</>}{!pending&&<button className="text-button" disabled={busy} onClick={()=>{setError('');setReload(v=>v+1);}}>Empfänger neu laden</button>}{pending&&<p className="form-hint">Bitte denselben Speichervorgang erneut prüfen, bevor weitere Änderungen vorgenommen werden.</p>}</section>;
}
+2 -1
View File
@@ -2,7 +2,8 @@ import type { StopType } from "./types";
export type PhotoWorkflow = {number:number;name:string;defaultWorkflow:boolean;min:number;max:number;stationScope:string;positions:string[]};
export type PhotoSet = {number:number;name:string;defaultSet:boolean;workflows:PhotoWorkflow[]};
export type PhotoOptions = {configured:boolean;sets:PhotoSet[]};
export type ClassicPhotos = {disabled:boolean;required:boolean;minimum:number;maximum:number};
export type PhotoOptions = {configured:boolean;sets:PhotoSet[];classic?:ClassicPhotos|null};
export function photoFlows(set:PhotoSet|null,type:StopType) {
// Match PHP photoScopeMatches on stored tr_ware_from_to (PICKUP=2, DELIVERY=1).
return (set?.workflows??[]).filter(f=>{