Binde Auftragshistorie an und korrigiere Kundenanzeige

Ergänzt die lesende Historien-API mit Verlaufsanzeige und ordnet Auftragskunden dem effektiven Zahler zu. Enthält VS-Code-Debugkonfiguration, Dokumentation und Regressionstests.

Validiert mit Frontend-Build, 76 Backend-Tests und 25 Browsertests. Live-Datenbankprüfung wegen fehlender Erreichbarkeit offen.
This commit is contained in:
Sven committed 2026-10-07 20:40:58 +02:00
1 parent ac5ea374b9
commit 75e2bf2bec
24 files changed
+1069 -60

No files matched your search

+102 -35
View File
@@ -48,6 +48,7 @@ import {
import type {
Courier,
Customer,
History,
Invoice,
Order,
OrderStatus,
@@ -117,6 +118,10 @@ const initials = (s: string) =>
.map((v) => v[0])
.join("")
.toUpperCase();
const customerLabel = (id: number | null | undefined, name?: string | null) =>
name?.trim() || (id != null && id > 0 ? `Kunde #${id}` : "Kunde nicht zugeordnet");
const orderCustomerLabel = (order: Order, customer?: Customer) =>
customerLabel(order.customerId, order.customerName?.trim() || customer?.name);
const emptyStop = (sequence: number): Stop => ({
sequence,
type: sequence === 1 ? "PICKUP" : "DELIVERY",
@@ -540,7 +545,13 @@ function Workspace({
? !!system?.capabilities.writeInvoices
: canEdit;
const customerName = useCallback(
(id: number) => customers.find((c) => c.id === id)?.name || `Kunde #${id}`,
(id: number | null | undefined) =>
customerLabel(id, customers.find((c) => c.id === id)?.name),
[customers],
);
const orderCustomerName = useCallback(
(order: Order) =>
orderCustomerLabel(order, customers.find((c) => c.id === order.customerId)),
[customers],
);
const courierName = useCallback(
@@ -1264,7 +1275,7 @@ function Workspace({
</td>
<td>
<strong className="truncate customer-cell">
{customerName(o.customerId)}
{orderCustomerName(o)}
</strong>
<span className="cell-sub">
{o.reference || "Keine Referenz"}
@@ -1418,7 +1429,7 @@ function Workspace({
<strong>{o.number}</strong>
<span>{time(o.pickupAt)}</span>
</span>
<h4>{customerName(o.customerId)}</h4>
<h4>{orderCustomerName(o)}</h4>
<p>
<MapPin size={13} />
{o.stops[0]?.city} <ArrowRight size={12} />
@@ -1514,7 +1525,7 @@ function Workspace({
label={o.legacyStatusLabel}
/>
</div>
<h4>{customerName(o.customerId)}</h4>
<h4>{orderCustomerName(o)}</h4>
{o.stops.map((s, i) => (
<p key={i}>
<i
@@ -1880,6 +1891,7 @@ function Workspace({
)}
{selected && (
<OrderDetails
key={selected.id}
order={selected}
customer={customers.find((c) => c.id === selected.customerId)}
couriers={couriers}
@@ -2085,6 +2097,33 @@ function OrderDetails({
const [tab, setTab] = useState("details"),
[courier, setCourier] = useState(String(o.courierId || "")),
[recipient, setRecipient] = useState("");
const [history, setHistory] = useState<{
order: Order;
entries?: History[];
error?: string;
} | null>(null);
const [historyAttempt, setHistoryAttempt] = useState(0);
// A refreshed order invalidates the displayed history even if its version
// did not change (legacy log entries may be written independently).
const currentHistory = history?.order === o ? history : null;
useEffect(() => {
if (tab !== "history") return;
const controller = new AbortController();
setHistory({ order: o });
void request<History[]>(`/orders/${o.id}/history`, {
signal: controller.signal,
}).then(
(entries) => {
if (!controller.signal.aborted)
setHistory({ order: o, entries: entries.slice().reverse() });
},
(error: unknown) => {
if (!controller.signal.aborted)
setHistory({ order: o, error: (error as Error).message });
},
);
return () => controller.abort();
}, [o, tab, historyAttempt]);
useEffect(() => setCourier(String(o.courierId || "")), [o.courierId]);
const actions: Record<OrderStatus, { status: OrderStatus; label: string }[]> =
{
@@ -2141,15 +2180,18 @@ function OrderDetails({
className={tab === "history" ? "active" : ""}
onClick={() => setTab("history")}
>
Verlauf <span>{o.history?.length || "–"}</span>
Verlauf{" "}
{currentHistory?.entries && (
<span>{currentHistory.entries.length}</span>
)}
</button>
</div>
<div className="drawer-body">
{tab === "details" ? (
<>
<div className="detail-section">
<span className="section-label">AUFTRAGGEBER</span>
<h3>{customer?.name || `Kunde #${o.customerId}`}</h3>
<span className="section-label">KUNDE / ZAHLER</span>
<h3>{orderCustomerLabel(o, customer)}</h3>
<p>
{customer?.contact} {customer?.phone && "· " + customer.phone}
</p>
@@ -2351,27 +2393,46 @@ function OrderDetails({
)}
</>
) : (
<div className="history-list">
{o.history?.length ? (
o.history
.slice()
.reverse()
.map((h) => (
<div className="history-item" key={h.id}>
<span className="history-dot" />
<div>
<strong>{h.action}</strong>
<p>{h.detail}</p>
<small>
{date(h.at)} · {time(h.at)} · {h.actor}
</small>
</div>
<div className="history-list" aria-label="Auftragshistorie">
{currentHistory?.error ? (
<div className="history-error">
<div className="alert error" role="alert">
<strong>Historie konnte nicht geladen werden.</strong>
<span>{currentHistory.error}</span>
</div>
<button
className="button"
onClick={() => setHistoryAttempt((attempt) => attempt + 1)}
>
<RefreshCw size={15} />
Erneut versuchen
</button>
</div>
) : !currentHistory?.entries ? (
<div className="history-loading" role="status">
<LoaderCircle className="spin" size={20} />
Historie wird geladen …
</div>
) : currentHistory.entries.length ? (
currentHistory.entries.map((h) => (
<div className="history-item" key={h.id}>
<span className="history-dot" />
<div>
<strong>{h.action}</strong>
<p>{h.detail}</p>
<small>
{h.at
? `${date(h.at)} · ${time(h.at)} Uhr`
: "Zeitpunkt unbekannt"}
{h.actor && ` · ${h.actor}`}
</small>
</div>
))
</div>
))
) : (
<Empty
title="Historie nicht angebunden"
detail="Die separate Legacy-Protokolldatenbank ist für diese Ansicht noch nicht angebunden."
title="Keine Historie vorhanden"
detail="Für diesen Auftrag sind keine Protokolleinträge vorhanden."
/>
)}
</div>
@@ -3383,15 +3444,21 @@ function Reports({
),
).sort((a, b) => b[1] - a[1]);
const customerRank = [...new Set(data.map((o) => o.customerId))]
.map((id) => ({
id,
name: customers.find((c) => c.id === id)?.name || `Kunde #${id}`,
number: customers.find((c) => c.id === id)?.number || "",
count: data.filter((o) => o.customerId === id).length,
total: sumValues(
data.filter((o) => o.customerId === id).map((o) => o.netPrice),
),
}))
.map((id) => {
const customerOrders = data.filter((o) => o.customerId === id);
const customer = customers.find((c) => c.id === id);
return {
id,
name: customerLabel(
id,
customerOrders.find((o) => o.customerName?.trim())?.customerName ||
customer?.name,
),
number: customer?.number || "",
count: customerOrders.length,
total: sumValues(customerOrders.map((o) => o.netPrice)),
};
})
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const courierRank = [
@@ -3523,7 +3590,7 @@ function Reports({
</thead>
<tbody>
{customerRank.map((c) => (
<tr key={c.id}>
<tr key={String(c.id)}>
<td>
<strong>{c.name}</strong>
<span className="cell-sub">{c.number}</span>
+14
View File
@@ -1840,6 +1840,18 @@ main {
.history-list {
padding: 25px 0;
}
.history-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 0;
color: var(--muted);
}
.history-error .alert {
flex-direction: column;
align-items: flex-start;
}
.history-item {
display: flex;
gap: 14px;
@@ -1878,6 +1890,8 @@ main {
font-size: 10px;
color: #b695c6;
margin-top: 5px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.history-item small {
display: block;
+4 -2
View File
@@ -16,7 +16,7 @@ export interface Stop {
}
export interface History {
id: number;
at: string;
at: string | null;
action: string;
detail: string;
actor: string;
@@ -31,7 +31,9 @@ export interface Order {
legacyStorno?: number;
isCreditNote?: boolean;
payerCustomerId?: number | null;
customerId: number;
ordererCustomerId?: number | null;
customerId: number | null;
customerName?: string | null;
courierId: number | null;
service: string;
vehicle: string;