Files
votianng/frontend/src/CustomerOrderEvidence.tsx
T
Sven 781a2b82dc 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.
2026-10-20 11:19:43 +02:00

28 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {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>;
}