diff --git a/src/admin/lib/documentStatus.ts b/src/admin/lib/documentStatus.ts new file mode 100644 index 0000000..6168d24 --- /dev/null +++ b/src/admin/lib/documentStatus.ts @@ -0,0 +1,101 @@ +/** + * Single source of truth for document status labels (Czech) + chip colors + * (MUI semantic) across the Offers / Invoices / Orders modules. + * + * Previously every list AND detail page defined its own `STATUS_LABELS` + + * `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status + * was `warning` in the list but `info` in the detail. This module consolidates + * them so the chip color/label for a given status is identical everywhere. + * + * Status KEYS are the ACTUAL values stored in the database and are kept AS-IS + * (e.g. customer orders are still keyed in Czech: `prijata` / `v_realizaci` / + * …). A Czech→English rename is a separate, deferred DB migration. + * + * The color union matches the kit `StatusChip` (src/admin/ui/StatusChip.tsx). + */ + +export type StatusColor = "default" | "info" | "warning" | "success" | "error"; + +export interface StatusMeta { + label: string; + color: StatusColor; +} + +/** + * OFFER / quotations. Stored statuses are `active` (default) / `ordered` / + * `invalidated` (see offers.service.ts). Labels are taken from the Offers + * filter tabs; colors are synthesized to match the list row tints + * (active = neutral-active info, ordered = success, invalidated = error). + * `expired` is a defensive entry — it is a FRONT-END derived state (computed + * from `valid_until`), not a stored status, but mapping it keeps any future + * stored value rendering consistently (warning, matching the expired row wash). + */ +export const OFFER_STATUS: Record = { + active: { label: "Aktivní", color: "info" }, + ordered: { label: "Objednaná", color: "success" }, + invalidated: { label: "Zneplatněná", color: "error" }, + expired: { label: "Po platnosti", color: "warning" }, +}; + +/** + * CUSTOMER ORDER / objednávka přijatá (orders). Czech-keyed (legacy-PHP + * carryover; rename is a deferred migration). + */ +export const ORDER_STATUS: Record = { + prijata: { label: "Přijatá", color: "info" }, + v_realizaci: { label: "V realizaci", color: "warning" }, + dokoncena: { label: "Dokončená", color: "success" }, + stornovana: { label: "Stornována", color: "error" }, +}; + +/** ISSUED ORDER / objednávka vydaná (issued_orders). */ +export const ISSUED_ORDER_STATUS: Record = { + draft: { label: "Koncept", color: "default" }, + sent: { label: "Odeslaná", color: "info" }, + confirmed: { label: "Potvrzená", color: "warning" }, + completed: { label: "Dokončená", color: "success" }, + cancelled: { label: "Stornovaná", color: "error" }, +}; + +/** + * ISSUED INVOICE (invoices). `issued` resolves to `warning` (payment-pending + * = warning, NOT info) — this fixes the list↔detail color drift. + */ +export const INVOICE_STATUS: Record = { + issued: { label: "Vystavena", color: "warning" }, + overdue: { label: "Po splatnosti", color: "error" }, + paid: { label: "Zaplacena", color: "success" }, +}; + +/** RECEIVED INVOICE (received_invoices). */ +export const RECEIVED_INVOICE_STATUS: Record = { + unpaid: { label: "Neuhrazena", color: "warning" }, + paid: { label: "Uhrazena", color: "success" }, +}; + +/** Label with a safe fallback for an unknown/empty key. */ +export const statusLabel = ( + m: Record, + k: string | null | undefined, +): string => (k && m[k]?.label) || k || "—"; + +/** Chip color with a safe fallback for an unknown/empty key. */ +export const statusColor = ( + m: Record, + k: string | null | undefined, +): StatusColor => (k && m[k]?.color) || "default"; + +/** + * Build `{ value, label }` filter options from a status map, optionally + * prefixed with an "all" entry. Used by list-page status filters. + */ +export const statusOptions = ( + m: Record, + allOption?: { value: string; label: string }, +): { value: string; label: string }[] => { + const opts = Object.entries(m).map(([value, { label }]) => ({ + value, + label, + })); + return allOption ? [allOption, ...opts] : opts; +}; diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 8a0a7ec..a85b6ae 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -67,6 +67,11 @@ import { RichTextView, headerActionsSx, } from "../ui"; +import { + INVOICE_STATUS, + statusLabel, + statusColor, +} from "../lib/documentStatus"; const API_BASE = "/api/admin"; @@ -97,21 +102,6 @@ function addDaysLocalStr(days: number, base?: string): string { return `${result.getFullYear()}-${mm}-${dd}`; } -const STATUS_LABELS: Record = { - issued: "Vystavena", - paid: "Zaplacena", - overdue: "Po splatnosti", -}; - -const STATUS_COLORS: Record< - string, - "default" | "success" | "error" | "warning" | "info" -> = { - issued: "info", - paid: "success", - overdue: "error", -}; - const TRANSITION_LABELS: Record = { paid: "Zaplaceno" }; const BackIcon = ( @@ -1141,8 +1131,8 @@ export default function InvoiceDetail() { Faktura {invoice.invoice_number} @@ -1658,8 +1648,8 @@ export default function InvoiceDetail() { Faktura {invoice.invoice_number} ) : ( @@ -2249,7 +2239,7 @@ export default function InvoiceDetail() { onClose={() => setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav faktury" - message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${STATUS_LABELS[statusConfirm.status || ""]}"?`} + message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${statusLabel(INVOICE_STATUS, statusConfirm.status)}"?`} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } diff --git a/src/admin/pages/Invoices.tsx b/src/admin/pages/Invoices.tsx index 75f7042..8f5eb64 100644 --- a/src/admin/pages/Invoices.tsx +++ b/src/admin/pages/Invoices.tsx @@ -45,6 +45,11 @@ import { type TabDef, type StatCardColor, } from "../ui"; +import { + INVOICE_STATUS, + statusLabel, + statusColor, +} from "../lib/documentStatus"; const ReceivedInvoices = lazy(() => import("./ReceivedInvoices")); const API_BASE = "/api/admin"; @@ -86,21 +91,6 @@ function formatCzkWithDetail( return { value: formatMultiCurrency(amounts), detail: null }; } -const STATUS_LABELS: Record = { - issued: "Vystavena", - paid: "Zaplacena", - overdue: "Po splatnosti", -}; - -const STATUS_COLORS: Record< - string, - "default" | "success" | "warning" | "error" -> = { - issued: "warning", - paid: "success", - overdue: "error", -}; - const STATUS_FILTERS = [ { value: "", label: "Vše" }, { value: "issued", label: "Vystavené" }, @@ -560,13 +550,13 @@ export default function Invoices() { render: (inv) => inv.status === "paid" ? ( ) : ( toggleStatus(inv)} /> ), diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index f547003..b2c86ed 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -58,28 +58,14 @@ import { PageEnter, headerActionsSx, } from "../ui"; +import { + ISSUED_ORDER_STATUS, + statusLabel, + statusColor, +} from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const STATUS_LABELS: Record = { - draft: "Koncept", - sent: "Odeslaná", - confirmed: "Potvrzená", - completed: "Dokončená", - cancelled: "Stornovaná", -}; - -const STATUS_COLORS: Record< - string, - "default" | "success" | "error" | "warning" | "info" -> = { - draft: "default", - sent: "info", - confirmed: "warning", - completed: "success", - cancelled: "error", -}; - // Labels for the buttons that trigger a status transition. const TRANSITION_LABELS: Record = { sent: "Odeslat", @@ -862,8 +848,8 @@ export default function IssuedOrderDetail() { {isEdit && ( )} @@ -1281,9 +1267,10 @@ export default function IssuedOrderDetail() { onClose={() => setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav objednávky" - message={`Opravdu chcete změnit stav objednávky "${poNumber}" na "${ - STATUS_LABELS[statusConfirm.status || ""] || "" - }"?`} + message={`Opravdu chcete změnit stav objednávky "${poNumber}" na "${statusLabel( + ISSUED_ORDER_STATUS, + statusConfirm.status, + )}"?`} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } diff --git a/src/admin/pages/IssuedOrders.tsx b/src/admin/pages/IssuedOrders.tsx index cd9d701..c24629b 100644 --- a/src/admin/pages/IssuedOrders.tsx +++ b/src/admin/pages/IssuedOrders.tsx @@ -28,32 +28,19 @@ import { LoadingState, type DataColumn, } from "../ui"; +import { + ISSUED_ORDER_STATUS, + statusLabel, + statusColor, + statusOptions, +} from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const STATUS_LABELS: Record = { - draft: "Koncept", - sent: "Odeslaná", - confirmed: "Potvrzená", - completed: "Dokončená", - cancelled: "Stornovaná", -}; - -const STATUS_COLORS: Record< - string, - "default" | "success" | "error" | "warning" | "info" -> = { - draft: "default", - sent: "info", - confirmed: "warning", - completed: "success", - cancelled: "error", -}; - -const STATUS_OPTIONS = [ - { value: "", label: "Všechny stavy" }, - ...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })), -]; +const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, { + value: "", + label: "Všechny stavy", +}); const ViewIcon = ( ( ), }, diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 1c214e3..20aebec 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -76,6 +76,7 @@ import { PageEnter, headerActionsSx, } from "../ui"; +import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; @@ -1124,10 +1125,17 @@ export default function OfferDetail() { {isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} - {isInvalidated && ( - - )} - {isCompleted && } + {isEdit && + (isCompleted ? ( + // Completed is derived from the linked order, not the offer's + // own status — surface it (success) over the base chip. + + ) : ( + + ))} diff --git a/src/admin/pages/Offers.tsx b/src/admin/pages/Offers.tsx index dd9cba1..e39b07c 100644 --- a/src/admin/pages/Offers.tsx +++ b/src/admin/pages/Offers.tsx @@ -38,15 +38,19 @@ import { type DataColumn, type TabDef, } from "../ui"; +import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; const API_BASE = "/api/admin"; const DRAFT_KEY = "boha_offer_draft"; +// Filter tabs cover only the real stored offer statuses (the `expired` entry +// in OFFER_STATUS is a front-end-derived state, not a stored value). const STATUS_FILTERS = [ { value: "", label: "Vše" }, - { value: "active", label: "Aktivní" }, - { value: "ordered", label: "Objednaná" }, - { value: "invalidated", label: "Zneplatněná" }, + ...(["active", "ordered", "invalidated"] as const).map((value) => ({ + value, + label: statusLabel(OFFER_STATUS, value), + })), ]; interface Quotation { @@ -497,7 +501,7 @@ export default function Offers() { { key: "customer_name", header: "Zákazník", - width: "18%", + width: "13%", render: (q) => q.customer_name || "—", }, { @@ -511,22 +515,43 @@ export default function Offers() { { key: "valid_until", header: "Platnost", - width: "11%", + width: "10%", sortKey: "valid_until", mono: true, render: (q) => formatDate(q.valid_until), }, + { + key: "status", + header: "Stav", + width: "10%", + sortKey: "status", + render: (q) => { + // The offer's own status is `active`/`ordered`/`invalidated`. When its + // linked order is completed, surface that as "Dokončená" (success) — + // matching the OfferDetail header chip and the completed row tint. + const completed = + q.status !== "invalidated" && q.order_status === "dokoncena"; + return completed ? ( + + ) : ( + + ); + }, + }, { key: "currency", header: "Měna", - width: "8%", + width: "7%", sortKey: "currency", render: (q) => , }, { key: "total", header: "Celkem", - width: "12%", + width: "11%", align: "right", mono: true, bold: true, @@ -535,7 +560,7 @@ export default function Offers() { { key: "actions", header: "Akce", - width: "16%", + width: "15%", align: "right", render: (q) => { const isInvalidated = q.status === "invalidated"; diff --git a/src/admin/pages/OrdersReceived.tsx b/src/admin/pages/OrdersReceived.tsx index 1177eaf..78d0582 100644 --- a/src/admin/pages/OrdersReceived.tsx +++ b/src/admin/pages/OrdersReceived.tsx @@ -34,26 +34,10 @@ import { LoadingState, type DataColumn, } from "../ui"; +import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const STATUS_LABELS: Record = { - prijata: "Přijatá", - v_realizaci: "V realizaci", - dokoncena: "Dokončená", - stornovana: "Stornována", -}; - -const STATUS_COLORS: Record< - string, - "default" | "success" | "error" | "warning" | "info" -> = { - prijata: "info", - v_realizaci: "warning", - dokoncena: "success", - stornovana: "error", -}; - interface Order { id: number; order_number: string; @@ -381,8 +365,8 @@ export default function OrdersReceived({ sortKey: "status", render: (o) => ( ), }, diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index 6b9a9f8..64b3ecc 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -42,17 +42,14 @@ import { type DataColumn, type StatCardColor, } from "../ui"; +import { + RECEIVED_INVOICE_STATUS, + statusLabel, + statusColor, +} from "../lib/documentStatus"; const API_BASE = "/api/admin"; -const STATUS_LABELS: Record = { - unpaid: "Neuhrazena", - paid: "Uhrazena", -}; -const STATUS_COLORS: Record = { - unpaid: "warning", - paid: "success", -}; const DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"]; const DEFAULT_VAT_RATES = [0, 10, 12, 15, 21]; @@ -604,13 +601,13 @@ export default function ReceivedInvoices({ render: (inv) => inv.status === "paid" ? ( ) : ( toggleStatus(inv)} /> ),