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:
1 parent
ac5ea374b9
commit
75e2bf2bec
24 files changed
+1069
-60
No files matched your search
+102
-35
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user