feat(drafts): two-button create (Vytvořit / Uložit koncept) + draft finalize UX across offers/invoices/issued-orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,15 @@ export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
|
|||||||
paid: { label: "Uhrazena", color: "success" },
|
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. */
|
/** Label with a safe fallback for an unknown/empty key. */
|
||||||
export const statusLabel = (
|
export const statusLabel = (
|
||||||
m: Record<string, StatusMeta>,
|
m: Record<string, StatusMeta>,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
INVOICE_STATUS,
|
INVOICE_STATUS,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusColor,
|
statusColor,
|
||||||
|
documentNumberLabel,
|
||||||
} from "../lib/documentStatus";
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
@@ -107,6 +108,10 @@ function addDaysLocalStr(days: number, base?: string): string {
|
|||||||
|
|
||||||
const TRANSITION_LABELS: Record<string, string> = { paid: "Zaplaceno" };
|
const TRANSITION_LABELS: Record<string, string> = { 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 = (
|
const BackIcon = (
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -943,9 +948,7 @@ export default function InvoiceDetail() {
|
|||||||
invalidate: ["invoices", "orders"],
|
invalidate: ["invoices", "orders"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateSubmit = async (e?: React.FormEvent) => {
|
const handleCreateSubmit = async (targetStatus?: string) => {
|
||||||
e?.preventDefault();
|
|
||||||
|
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
|
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
|
||||||
if (!form.issue_date) newErrors.issue_date = "Zadejte datum";
|
if (!form.issue_date) newErrors.issue_date = "Zadejte datum";
|
||||||
@@ -974,7 +977,14 @@ export default function InvoiceDetail() {
|
|||||||
position: i,
|
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);
|
const data = await saveMutation.mutateAsync(payload);
|
||||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
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 ───
|
// ─── Edit mode: status change ───
|
||||||
const handleStatusChange = async () => {
|
const handleStatusChange = async () => {
|
||||||
if (!statusConfirm.status) return;
|
if (!statusConfirm.status) return;
|
||||||
@@ -1098,7 +1118,7 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
Faktura {invoice.invoice_number}
|
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||||
@@ -1619,7 +1639,7 @@ export default function InvoiceDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
Faktura {invoice.invoice_number}
|
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||||
@@ -1667,18 +1687,105 @@ export default function InvoiceDetail() {
|
|||||||
Zobrazit fakturu
|
Zobrazit fakturu
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleCreateSubmit} disabled={saving}>
|
{/* ── Create mode: two-button save ── */}
|
||||||
{saving ? (
|
{!isEdit && (
|
||||||
<>
|
|
||||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
|
||||||
Ukládání...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Uložit"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{isEdit && invoice && (
|
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleCreateSubmit("draft")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"Uložit koncept"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCreateSubmit(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Vytvořit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit mode: draft → save-draft + finalize ── */}
|
||||||
|
{isEdit && invoice && invoice.status === "draft" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleCreateSubmit("draft")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"Uložit koncept"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{hasPermission("invoices.edit") && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleCreateSubmit(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Vystavit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* A draft is deleted rather than cancelled. */}
|
||||||
|
{hasPermission("invoices.delete") && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
Smazat
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit mode: live (non-draft) doc — unchanged ── */}
|
||||||
|
{isEdit && invoice && invoice.status !== "draft" && (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => handleCreateSubmit()} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Uložit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
{hasPermission("invoices.edit") &&
|
{hasPermission("invoices.edit") &&
|
||||||
invoice.valid_transitions?.map((status) => (
|
invoice.valid_transitions?.map((status) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -1723,7 +1830,7 @@ export default function InvoiceDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handleCreateSubmit}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
{/* Basic info */}
|
{/* Basic info */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
|||||||
@@ -65,10 +65,15 @@ import {
|
|||||||
ISSUED_ORDER_STATUS,
|
ISSUED_ORDER_STATUS,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusColor,
|
statusColor,
|
||||||
|
documentNumberLabel,
|
||||||
} from "../lib/documentStatus";
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
|
// The live (finalized) status for an issued order — assigning it (on create or
|
||||||
|
// on finalizing a draft) makes the backend consume the official PO number.
|
||||||
|
const LIVE_STATUS = "sent";
|
||||||
|
|
||||||
// Labels for the buttons that trigger a status transition.
|
// Labels for the buttons that trigger a status transition.
|
||||||
const TRANSITION_LABELS: Record<string, string> = {
|
const TRANSITION_LABELS: Record<string, string> = {
|
||||||
sent: "Odeslat",
|
sent: "Odeslat",
|
||||||
@@ -627,6 +632,17 @@ export default function IssuedOrderDetail() {
|
|||||||
customersQuery.isLoading,
|
customersQuery.isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Keep the displayed PO number in sync once it becomes available — a draft
|
||||||
|
// has none until it is finalized (draft → sent), at which point the
|
||||||
|
// invalidated detail query refetches with the freshly-assigned number. The
|
||||||
|
// one-shot hydration effect above won't re-run (dataReady stays true), so
|
||||||
|
// mirror the latest non-empty po_number here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit && detail?.po_number && detail.po_number !== poNumber) {
|
||||||
|
setPoNumber(detail.po_number);
|
||||||
|
}
|
||||||
|
}, [isEdit, detail?.po_number, poNumber]);
|
||||||
|
|
||||||
// Form is editable only in create mode or while draft/sent.
|
// Form is editable only in create mode or while draft/sent.
|
||||||
const editable = !isEdit || form.status === "draft" || form.status === "sent";
|
const editable = !isEdit || form.status === "draft" || form.status === "sent";
|
||||||
const canExport = hasPermission("orders.export");
|
const canExport = hasPermission("orders.export");
|
||||||
@@ -706,9 +722,7 @@ export default function IssuedOrderDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Submit (create + edit) ───
|
// ─── Submit (create + edit) ───
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (targetStatus?: string) => {
|
||||||
e?.preventDefault();
|
|
||||||
|
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
|
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
|
||||||
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
||||||
@@ -747,11 +761,21 @@ export default function IssuedOrderDetail() {
|
|||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
// Only set status when a target is given (create-as-draft / create-as-live
|
||||||
|
// / finalize). Editing a live order sends no status — the backend keeps
|
||||||
|
// its current status. The PO number is never user-editable.
|
||||||
|
if (targetStatus) payload.status = targetStatus;
|
||||||
|
|
||||||
const data = await saveMutation.mutateAsync(payload);
|
const data = await saveMutation.mutateAsync(payload);
|
||||||
alert.success(
|
alert.success(
|
||||||
isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena",
|
isEdit ? "Objednávka byla uložena" : "Objednávka byla vytvořena",
|
||||||
);
|
);
|
||||||
|
// Finalizing a draft (draft → sent) assigns the PO number server-side;
|
||||||
|
// reflect the new status locally so the form switches out of the draft
|
||||||
|
// button branch (the invalidated query refetch repopulates the number).
|
||||||
|
if (isEdit && targetStatus && targetStatus !== "draft") {
|
||||||
|
setForm((prev) => ({ ...prev, status: targetStatus }));
|
||||||
|
}
|
||||||
if (!isEdit) navigate(`/orders/issued/${data.id}`);
|
if (!isEdit) navigate(`/orders/issued/${data.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert.error(
|
alert.error(
|
||||||
@@ -766,6 +790,16 @@ export default function IssuedOrderDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 order → plain save (no status change).
|
||||||
|
const handleFormSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isEdit) void handleSubmit(LIVE_STATUS);
|
||||||
|
else if (form.status === "draft") void handleSubmit("draft");
|
||||||
|
else void handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Status transition ───
|
// ─── Status transition ───
|
||||||
const handleStatusChange = async () => {
|
const handleStatusChange = async () => {
|
||||||
if (!statusConfirm.status) return;
|
if (!statusConfirm.status) return;
|
||||||
@@ -866,12 +900,14 @@ export default function IssuedOrderDetail() {
|
|||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
{isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"}
|
{isEdit ? "Objednávka vydaná" : "Nová objednávka vydaná"}
|
||||||
{poNumber && (
|
{/* Edit mode: a draft has no PO number yet → show "Koncept".
|
||||||
|
Create mode: show the previewed next-number when available. */}
|
||||||
|
{(isEdit || poNumber) && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||||
>
|
>
|
||||||
{poNumber}
|
{isEdit ? documentNumberLabel(poNumber) : poNumber}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -901,35 +937,118 @@ export default function IssuedOrderDetail() {
|
|||||||
Export PDF
|
Export PDF
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{/* ── Create mode: two-button save ── */}
|
||||||
<Button onClick={handleSubmit} disabled={saving}>
|
{!isEdit && (
|
||||||
{saving ? (
|
<>
|
||||||
<>
|
|
||||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
|
||||||
Ukládání...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Uložit"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isEdit &&
|
|
||||||
hasPermission("orders.edit") &&
|
|
||||||
detail?.valid_transitions?.map((status) => (
|
|
||||||
<Button
|
<Button
|
||||||
key={status}
|
variant="outlined"
|
||||||
onClick={() => setStatusConfirm({ show: true, status })}
|
color="inherit"
|
||||||
variant={status === "cancelled" ? "outlined" : "contained"}
|
onClick={() => handleSubmit("draft")}
|
||||||
color={status === "cancelled" ? "error" : "primary"}
|
disabled={saving}
|
||||||
disabled={statusChanging === status}
|
|
||||||
>
|
>
|
||||||
{statusChanging === status ? (
|
{saving ? (
|
||||||
<CircularProgress size={14} color="inherit" />
|
<CircularProgress size={16} color="inherit" />
|
||||||
) : (
|
) : (
|
||||||
TRANSITION_LABELS[status] || status
|
"Uložit koncept"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
<Button
|
||||||
|
onClick={() => handleSubmit(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Vytvořit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 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" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleSubmit("draft")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"Uložit koncept"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{hasPermission("orders.edit") && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSubmit(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Odeslat"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit mode, live (non-draft) order — unchanged save + transitions ── */}
|
||||||
|
{isEdit && form.status !== "draft" && (
|
||||||
|
<>
|
||||||
|
{editable && (
|
||||||
|
<Button onClick={() => handleSubmit()} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Uložit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission("orders.edit") &&
|
||||||
|
detail?.valid_transitions?.map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
onClick={() => setStatusConfirm({ show: true, status })}
|
||||||
|
variant={status === "cancelled" ? "outlined" : "contained"}
|
||||||
|
color={status === "cancelled" ? "error" : "primary"}
|
||||||
|
disabled={statusChanging === status}
|
||||||
|
>
|
||||||
|
{statusChanging === status ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
TRANSITION_LABELS[status] || status
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* A completed order is terminal/finalized — never deletable from the
|
{/* A completed order is terminal/finalized — never deletable from the
|
||||||
UI. draft/sent/confirmed/cancelled may be deleted. (Backend
|
UI. draft/sent/confirmed/cancelled may be deleted. (Backend
|
||||||
rejection of deleting an order is a separate hardening, out of
|
rejection of deleting an order is a separate hardening, out of
|
||||||
@@ -948,7 +1067,7 @@ export default function IssuedOrderDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleFormSubmit}>
|
||||||
{/* Basic info */}
|
{/* Basic info */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
|||||||
@@ -76,10 +76,19 @@ import {
|
|||||||
CustomerPicker,
|
CustomerPicker,
|
||||||
headerActionsSx,
|
headerActionsSx,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
import { OFFER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
import {
|
||||||
|
OFFER_STATUS,
|
||||||
|
statusLabel,
|
||||||
|
statusColor,
|
||||||
|
documentNumberLabel,
|
||||||
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
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 {
|
interface OfferItem {
|
||||||
_key: string;
|
_key: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -788,7 +797,7 @@ export default function OfferDetail() {
|
|||||||
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
|
const vatAmount = form.apply_vat ? subtotal * (form.vat_rate / 100) : 0;
|
||||||
const total = subtotal + vatAmount;
|
const total = subtotal + vatAmount;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (targetStatus?: string) => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
|
if (!form.customer_id) newErrors.customer_id = "Zákazník je povinný";
|
||||||
if (!form.created_at) newErrors.created_at = "Datum 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 })),
|
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, {
|
const response = await apiFetch(url, {
|
||||||
method: isEdit ? "PUT" : "POST",
|
method: isEdit ? "PUT" : "POST",
|
||||||
@@ -822,7 +837,9 @@ export default function OfferDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const offerId = isEdit ? id : result.data?.id;
|
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(
|
await apiFetch(`${API_BASE}/offers-pdf/${offerId}?save=1`).catch(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
@@ -844,6 +861,16 @@ export default function OfferDetail() {
|
|||||||
items,
|
items,
|
||||||
sections,
|
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: ["offers"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
@@ -1030,7 +1057,9 @@ export default function OfferDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h4">
|
<Typography variant="h4">
|
||||||
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
|
{isEdit
|
||||||
|
? `Nabídka ${documentNumberLabel(form.quotation_number)}`
|
||||||
|
: "Nová nabídka"}
|
||||||
</Typography>
|
</Typography>
|
||||||
{isEdit &&
|
{isEdit &&
|
||||||
(isCompleted ? (
|
(isCompleted ? (
|
||||||
@@ -1098,8 +1127,81 @@ export default function OfferDetail() {
|
|||||||
Zneplatnit
|
Zneplatnit
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!readOnly && (
|
{/* ── Create mode: two-button save ── */}
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
{!isEdit && !readOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleSave("draft")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"Uložit koncept"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSave(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Vytvořit"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit mode, draft: save-draft + finalize (Aktivovat) ── */}
|
||||||
|
{isEdit && !readOnly && offerStatus === "draft" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleSave("draft")}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
"Uložit koncept"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{hasPermission("offers.edit") && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSave(LIVE_STATUS)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress
|
||||||
|
size={16}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
/>
|
||||||
|
Ukládání...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Aktivovat"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Edit mode, live (non-draft) offer — unchanged plain save ── */}
|
||||||
|
{isEdit && !readOnly && offerStatus !== "draft" && (
|
||||||
|
<Button onClick={() => handleSave()} disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
|
|||||||
Reference in New Issue
Block a user