feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-16 20:20:51 +02:00
parent 44cfea22ca
commit 3d784adf5d
3 changed files with 65 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
import SectionsEditor, { import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -611,6 +612,9 @@ export default function InvoiceDetail() {
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF — // Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders). // shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [items, setItems] = useState<InvoiceItem[]>(() => [ const [items, setItems] = useState<InvoiceItem[]>(() => [
{ {
_key: "inv-1", _key: "inv-1",
@@ -821,11 +825,15 @@ export default function InvoiceDetail() {
: []; : [];
setSections(mappedSections); setSections(mappedSections);
const mappedCustomFields = inv.selected_custom_fields ?? [];
setSelectedCustomFields(mappedCustomFields);
// Capture initial snapshot for dirty-checking // Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({ initialSnapshotRef.current = JSON.stringify({
form: formData, form: formData,
items: mappedItems, items: mappedItems,
sections: mappedSections, sections: mappedSections,
selectedCustomFields: mappedCustomFields,
}); });
setDataReady(true); setDataReady(true);
@@ -923,15 +931,21 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data. // Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form. // Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) { if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections }); initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
} }
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false; if (!initialSnapshotRef.current) return false;
return ( return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current JSON.stringify({ form, items, sections, selectedCustomFields }) !==
initialSnapshotRef.current
); );
}, [form, items, sections]); }, [form, items, sections, selectedCustomFields]);
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
@@ -1095,6 +1109,7 @@ export default function InvoiceDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1107,7 +1122,12 @@ export default function InvoiceDetail() {
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");
initialSnapshotRef.current = JSON.stringify({ form, items, sections }); initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
if (!isEdit) { if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`); navigate(`/invoices/${data.invoice_id}`);
} }
@@ -2209,6 +2229,15 @@ export default function InvoiceDetail() {
]} ]}
/> />
</Field> </Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={false}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items */} {/* Items */}

View File

@@ -23,6 +23,7 @@ import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner"; import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock"; import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
internal_notes: string; internal_notes: string;
items: IssuedOrderItemPayload[]; items: IssuedOrderItemPayload[];
sections: IssuedOrderSectionPayload[]; sections: IssuedOrderSectionPayload[];
selected_custom_fields: number[];
status?: string; status?: string;
} }
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
emptyDocumentItem(), emptyDocumentItem(),
]); ]);
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | the live status | "save"), so // Which save action is in flight ("draft" | the live status | "save"), so
@@ -308,6 +313,7 @@ export default function IssuedOrderDetail() {
})) }))
: []; : [];
setSections(mappedSections); setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
setLockedBy(d.locked_by ?? null); setLockedBy(d.locked_by ?? null);
// Acquire the edit lock when nobody holds it, the order is still // Acquire the edit lock when nobody holds it, the order is still
@@ -430,6 +436,7 @@ export default function IssuedOrderDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / finalize). Editing a live order sends no status — the backend keeps
@@ -874,6 +881,15 @@ export default function IssuedOrderDetail() {
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)" placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
/> />
</Field> </Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!editable}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items */} {/* Items */}

View File

@@ -33,6 +33,7 @@ import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner"; import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock"; import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -114,6 +115,7 @@ interface OfferSavePayload {
language: string; language: string;
items: OfferItemPayload[]; items: OfferItemPayload[];
sections: OfferSectionPayload[]; sections: OfferSectionPayload[];
selected_custom_fields: number[];
status?: string; status?: string;
} }
@@ -178,6 +180,9 @@ export default function OfferDetail() {
emptyDocumentItem(), emptyDocumentItem(),
]); ]);
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null); const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>(""); const [offerStatus, setOfferStatus] = useState<string>("");
const [quotationNumber, setQuotationNumber] = useState(""); const [quotationNumber, setQuotationNumber] = useState("");
@@ -318,6 +323,7 @@ export default function OfferDetail() {
})) }))
: []; : [];
setSections(mappedSections); setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
markClean({ form: formData, items: mappedItems, sections: mappedSections }); markClean({ form: formData, items: mappedItems, sections: mappedSections });
setOfferStatus(d.status || ""); setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null); setOrderInfo(d.order ?? null);
@@ -445,6 +451,7 @@ export default function OfferDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / finalize). Editing a live offer sends no status — the backend keeps
@@ -929,6 +936,15 @@ export default function OfferDetail() {
/> />
</Field> </Field>
</Box> </Box>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={readOnly}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items (drag-and-drop, offers carry the "V ceně" column) */} {/* Items (drag-and-drop, offers carry the "V ceně" column) */}