diff --git a/src/admin/lib/documentStatus.ts b/src/admin/lib/documentStatus.ts index c45b384..b97973a 100644 --- a/src/admin/lib/documentStatus.ts +++ b/src/admin/lib/documentStatus.ts @@ -75,6 +75,15 @@ export const RECEIVED_INVOICE_STATUS: Record = { paid: { label: "Uhrazena", color: "success" }, }; +/** + * Display label for a document's official number. Drafts (deferred numbering) + * have a NULL/empty number until they are finalized — show "Koncept" instead + * of an empty/`null` heading. + */ +export function documentNumberLabel(num: string | null | undefined): string { + return num && String(num).trim() ? String(num) : "Koncept"; +} + /** Label with a safe fallback for an unknown/empty key. */ export const statusLabel = ( m: Record, diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 5dfe713..ac62cc7 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -74,6 +74,7 @@ import { INVOICE_STATUS, statusLabel, statusColor, + documentNumberLabel, } from "../lib/documentStatus"; const API_BASE = "/api/admin"; @@ -107,6 +108,10 @@ function addDaysLocalStr(days: number, base?: string): string { const TRANSITION_LABELS: Record = { paid: "Zaplaceno" }; +// The live (finalized) status for an invoice — assigning it (on create or on +// finalizing a draft) makes the backend consume the official invoice number. +const LIVE_STATUS = "issued"; + const BackIcon = ( { - e?.preventDefault(); - + const handleCreateSubmit = async (targetStatus?: string) => { const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka"; if (!form.issue_date) newErrors.issue_date = "Zadejte datum"; @@ -974,7 +977,14 @@ export default function InvoiceDetail() { position: i, })), }; - if (isEdit) payload.invoice_number = invoiceNumber; + // Only set status when a target is given (create-as-draft / create-as-live + // / finalize). Editing a live invoice sends no status — the backend keeps + // its current status. + if (targetStatus) payload.status = targetStatus; + // The invoice number is NOT user-editable for drafts (it is NULL until + // finalized). Send it back only when editing an already-numbered invoice. + if (isEdit && invoice?.status !== "draft") + payload.invoice_number = invoiceNumber; const data = await saveMutation.mutateAsync(payload); alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); @@ -995,6 +1005,16 @@ export default function InvoiceDetail() { } }; + // Native form submit (e.g. Enter): route to the right save path. Create → + // finalize live; editing a draft → save draft (the visible primary button); + // editing a live invoice → plain save (no status change). + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isEdit) void handleCreateSubmit(LIVE_STATUS); + else if (invoice?.status === "draft") void handleCreateSubmit("draft"); + else void handleCreateSubmit(); + }; + // ─── Edit mode: status change ─── const handleStatusChange = async () => { if (!statusConfirm.status) return; @@ -1098,7 +1118,7 @@ export default function InvoiceDetail() { }} > - Faktura {invoice.invoice_number} + Faktura {documentNumberLabel(invoice.invoice_number)} - Faktura {invoice.invoice_number} + Faktura {documentNumberLabel(invoice.invoice_number)} )} - - {isEdit && invoice && ( + {/* ── Create mode: two-button save ── */} + {!isEdit && ( <> + + + + )} + + {/* ── Edit mode: draft → save-draft + finalize ── */} + {isEdit && invoice && invoice.status === "draft" && ( + <> + + {hasPermission("invoices.edit") && ( + + )} + {/* A draft is deleted rather than cancelled. */} + {hasPermission("invoices.delete") && ( + + )} + + )} + + {/* ── Edit mode: live (non-draft) doc — unchanged ── */} + {isEdit && invoice && invoice.status !== "draft" && ( + <> + {hasPermission("invoices.edit") && invoice.valid_transitions?.map((status) => ( )} - {editable && ( - - )} - {isEdit && - hasPermission("orders.edit") && - detail?.valid_transitions?.map((status) => ( + {/* ── Create mode: two-button save ── */} + {!isEdit && ( + <> - ))} + + + )} + + {/* ── Edit mode, draft: save-draft + finalize (Odeslat). A draft is + deleted rather than cancelled, so the generic transition buttons + (sent/cancelled) are suppressed — the finalize button IS the + draft → sent transition. ── */} + {isEdit && form.status === "draft" && ( + <> + + {hasPermission("orders.edit") && ( + + )} + + )} + + {/* ── Edit mode, live (non-draft) order — unchanged save + transitions ── */} + {isEdit && form.status !== "draft" && ( + <> + {editable && ( + + )} + {hasPermission("orders.edit") && + detail?.valid_transitions?.map((status) => ( + + ))} + + )} {/* A completed order is terminal/finalized — never deletable from the UI. draft/sent/confirmed/cancelled may be deleted. (Backend rejection of deleting an order is a separate hardening, out of @@ -948,7 +1067,7 @@ export default function IssuedOrderDetail() { -
+
{/* Basic info */} diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 9746033..a80c56c 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -76,10 +76,19 @@ import { CustomerPicker, headerActionsSx, } from "../ui"; -import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; +import { + OFFER_STATUS, + statusLabel, + statusColor, + documentNumberLabel, +} from "../lib/documentStatus"; const API_BASE = "/api/admin"; +// The live (finalized) status for an offer — assigning it (on create or on +// finalizing a draft) makes the backend consume the official quotation number. +const LIVE_STATUS = "active"; + interface OfferItem { _key: string; id?: number; @@ -788,7 +797,7 @@ export default function OfferDetail() { const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0; const total = subtotal + vatAmount; - const handleSave = async () => { + const handleSave = async (targetStatus?: string) => { const newErrors: Record = {}; if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný"; if (!form.created_at) newErrors.created_at = "Datum je povinné"; @@ -812,7 +821,13 @@ export default function OfferDetail() { })), sections: sections.map((s, i) => ({ ...s, position: i })), }; - if (!isEdit) delete payload.quotation_number; + // Only set status when a target is given (create-as-draft / create-as-live + // / finalize). Editing a live offer sends no status — the backend keeps + // its current status. + if (targetStatus) payload.status = targetStatus; + // The quotation number is NOT user-editable for drafts (it is NULL until + // finalized). Strip it on create AND when finalizing an existing draft. + if (!isEdit || offerStatus === "draft") delete payload.quotation_number; const response = await apiFetch(url, { method: isEdit ? "PUT" : "POST", @@ -822,7 +837,9 @@ export default function OfferDetail() { const result = await response.json(); if (result.success) { const offerId = isEdit ? id : result.data?.id; - if (offerId) { + // A draft has no number → skip the PDF save. Only generate/save the PDF + // on a live create or a finalize (targetStatus !== "draft"). + if (offerId && targetStatus !== "draft") { await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch( () => {}, ); @@ -844,6 +861,16 @@ export default function OfferDetail() { items, sections, }); + // Finalizing a draft (draft → active) assigns the official number + // server-side. Reflect the new status immediately and re-hydrate the + // form from the refetched detail so the now-assigned number renders + // (the local state already equals what was just persisted, so the + // re-hydration is non-destructive). + if (targetStatus && targetStatus !== "draft") { + setOfferStatus(targetStatus); + formInitializedRef.current = false; + await queryClient.invalidateQueries({ queryKey: ["offers", id] }); + } queryClient.invalidateQueries({ queryKey: ["offers"] }); queryClient.invalidateQueries({ queryKey: ["orders"] }); queryClient.invalidateQueries({ queryKey: ["projects"] }); @@ -1030,7 +1057,9 @@ export default function OfferDetail() { }} > - {isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"} + {isEdit + ? `Nabídka ${documentNumberLabel(form.quotation_number)}` + : "Nová nabídka"} {isEdit && (isCompleted ? ( @@ -1098,8 +1127,81 @@ export default function OfferDetail() { Zneplatnit )} - {!readOnly && ( -