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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user