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:
BOHA
2026-06-09 18:44:10 +02:00
parent ce636215dc
commit e86c2e9a80
4 changed files with 392 additions and 55 deletions

View File

@@ -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<string, string> = {};
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() {
}}
>
<Typography variant="h4">
{isEdit ? `Nabídka ${form.quotation_number}` : "Nová nabídka"}
{isEdit
? `Nabídka ${documentNumberLabel(form.quotation_number)}`
: "Nová nabídka"}
</Typography>
{isEdit &&
(isCompleted ? (
@@ -1098,8 +1127,81 @@ export default function OfferDetail() {
Zneplatnit
</Button>
)}
{!readOnly && (
<Button onClick={handleSave} disabled={saving}>
{/* ── Create mode: two-button save ── */}
{!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 ? (
<>
<CircularProgress