feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer
Invoices now mirror the issued-orders document model: - New invoice_sections table (CZ/EN rich-text "Obsah") edited via the shared SectionsEditor, printed inline right after the items on the PDF. Full-replace on update, same transaction as items. - Printed notes dropped: the notes column is removed (migration merges existing content into internal_notes first); the form field is now "Interni poznamky", never printed. Legacy payloads sending notes are silently stripped. - Form cleanup: Cislo faktury and Vystavil fields removed (number lives in the header, issued_by auto-fills); page header title restyled to the orders/offers pattern (number span + status chip). - Unified per-page PDF header for the red-accent family: shared buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red rule) rendered by a Puppeteer headerTemplate on EVERY page of both invoices and issued orders (incl. the /file fallback render); body headers are print-hidden. htmlToPdf gained the headerTemplate option. - Footer parity: invoices get the per-page "Vystavil + Strana X z Y" footer; the invoice bottom block (notice + QR/VAT recap + Prevzal) is break-inside: avoid so a page break can never split it. - @page margins now match the template space (32mm top, 18mm bottom) - Chromium lays out by CSS @page margins, which also fixes issued orders' content running into the 18mm footer zone on full pages. BREAKING CHANGE: invoices.notes column dropped (data merged into internal_notes); deploy must run prisma migrate deploy + generate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,14 @@ export interface InvoiceItem {
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceSection {
|
||||
id?: number;
|
||||
title: string | null;
|
||||
title_cz: string | null;
|
||||
content: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
|
||||
vat_rate: number;
|
||||
apply_vat: boolean;
|
||||
exchange_rate: string;
|
||||
notes?: string;
|
||||
internal_notes?: string | null;
|
||||
payment_method?: string;
|
||||
variable_symbol?: string;
|
||||
constant_symbol?: string;
|
||||
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
|
||||
bank_account?: string;
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
sections?: InvoiceSection[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
|
||||
@@ -24,6 +24,9 @@ import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import RichEditor from "../components/RichEditor";
|
||||
import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -198,7 +201,7 @@ interface InvoiceForm {
|
||||
constant_symbol: string;
|
||||
issued_by: string;
|
||||
billing_text: string;
|
||||
notes: string;
|
||||
internal_notes: string;
|
||||
language: string;
|
||||
bank_account_id: number | string;
|
||||
bank_name: string;
|
||||
@@ -540,7 +543,7 @@ export default function InvoiceDetail() {
|
||||
constant_symbol: "0308",
|
||||
issued_by: user?.fullName || "",
|
||||
billing_text: "",
|
||||
notes: "",
|
||||
internal_notes: "",
|
||||
language: "cs",
|
||||
bank_account_id: "",
|
||||
bank_name: "",
|
||||
@@ -550,6 +553,9 @@ export default function InvoiceDetail() {
|
||||
}));
|
||||
|
||||
const [dueDays, setDueDays] = useState(14);
|
||||
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
||||
// shared editor with offers/issued orders).
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_key: "inv-1",
|
||||
@@ -626,7 +632,6 @@ export default function InvoiceDetail() {
|
||||
});
|
||||
|
||||
// ─── Edit mode state ───
|
||||
const [notes, setNotes] = useState("");
|
||||
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
||||
const [statusConfirm, setStatusConfirm] = useState<{
|
||||
show: boolean;
|
||||
@@ -696,7 +701,7 @@ export default function InvoiceDetail() {
|
||||
constant_symbol: inv.constant_symbol || "0308",
|
||||
issued_by: inv.issued_by || "",
|
||||
billing_text: inv.billing_text || "",
|
||||
notes: inv.notes || "",
|
||||
internal_notes: inv.internal_notes || "",
|
||||
language: inv.language || "cs",
|
||||
bank_account_id: matchedBankId,
|
||||
bank_name: inv.bank_name || "",
|
||||
@@ -705,7 +710,6 @@ export default function InvoiceDetail() {
|
||||
bank_account: inv.bank_account || "",
|
||||
};
|
||||
setForm(formData);
|
||||
setNotes(inv.notes || "");
|
||||
setInvoiceNumber(inv.invoice_number || "");
|
||||
|
||||
// Calculate dueDays from existing dates
|
||||
@@ -741,10 +745,22 @@ export default function InvoiceDetail() {
|
||||
setItems(mappedItems);
|
||||
}
|
||||
|
||||
// Populate sections from existing invoice
|
||||
const mappedSections =
|
||||
Array.isArray(inv.sections) && inv.sections.length > 0
|
||||
? inv.sections.map((s) => ({
|
||||
title: s.title || "",
|
||||
title_cz: s.title_cz || "",
|
||||
content: s.content || "",
|
||||
}))
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
|
||||
// Capture initial snapshot for dirty-checking
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form: formData,
|
||||
items: mappedItems,
|
||||
sections: mappedSections,
|
||||
});
|
||||
|
||||
setDataReady(true);
|
||||
@@ -841,13 +857,15 @@ export default function InvoiceDetail() {
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
// Create mode: captured on the first render after sync effects populate the form.
|
||||
if (dataReady && !initialSnapshotRef.current) {
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
}
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!initialSnapshotRef.current) return false;
|
||||
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
|
||||
}, [form, items]);
|
||||
return (
|
||||
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
||||
);
|
||||
}, [form, items, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
@@ -1005,6 +1023,12 @@ export default function InvoiceDetail() {
|
||||
unit_price: Number(item.unit_price) || 0,
|
||||
position: i,
|
||||
})),
|
||||
sections: sections.map((s, i) => ({
|
||||
title: s.title,
|
||||
title_cz: s.title_cz,
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
};
|
||||
// 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
|
||||
@@ -1017,7 +1041,7 @@ export default function InvoiceDetail() {
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
if (!isEdit) {
|
||||
navigate(`/invoices/${data.invoice_id}`);
|
||||
}
|
||||
@@ -1148,7 +1172,13 @@ export default function InvoiceDetail() {
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
Faktura
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{documentNumberLabel(invoice.invoice_number)}
|
||||
</Box>
|
||||
</Typography>
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
@@ -1281,11 +1311,6 @@ export default function InvoiceDetail() {
|
||||
<Field label="Variabilní symbol">
|
||||
<Typography variant="body2">{invoice.invoice_number}</Typography>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<Typography variant="body2">
|
||||
{invoice.issued_by || "—"}
|
||||
</Typography>
|
||||
</Field>
|
||||
</Box>
|
||||
{invoice.paid_date && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
@@ -1597,14 +1622,55 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes (read-only) */}
|
||||
{/* Obsah sections (read-only) — shown only when any section has content */}
|
||||
{(invoice.sections ?? []).some(
|
||||
(s) =>
|
||||
(s.title || "").trim() ||
|
||||
(s.title_cz || "").trim() ||
|
||||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||
) && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Obsah
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{(invoice.sections ?? []).map((s, idx) => (
|
||||
<Box key={s.id ?? idx}>
|
||||
{((invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title) ||
|
||||
"") && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{invoice.language === "cs"
|
||||
? s.title_cz || s.title
|
||||
: s.title}
|
||||
</Typography>
|
||||
)}
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Internal notes (read-only, never printed) */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
Interní poznámky
|
||||
</Typography>
|
||||
{notes && notes.trim() && notes !== "<p><br></p>" ? (
|
||||
{invoice.internal_notes &&
|
||||
invoice.internal_notes.trim() &&
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -1659,43 +1725,44 @@ export default function InvoiceDetail() {
|
||||
Zpět
|
||||
</Button>
|
||||
<Box>
|
||||
{isEdit && invoice ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
Faktura {documentNumberLabel(invoice.invoice_number)}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? "Faktura" : "Nová faktura"}
|
||||
{/* Edit mode: a draft has no number yet → show "Koncept".
|
||||
Create mode: show the previewed next-number when available. */}
|
||||
{(isEdit || invoiceNumber) && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||||
>
|
||||
{isEdit
|
||||
? documentNumberLabel(invoice?.invoice_number)
|
||||
: invoiceNumber}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{isEdit && invoice && (
|
||||
<StatusChip
|
||||
label={statusLabel(INVOICE_STATUS, invoice.status)}
|
||||
color={statusColor(INVOICE_STATUS, invoice.status)}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="h4">
|
||||
Nová faktura{" "}
|
||||
{invoiceNumber && (
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
({invoiceNumber})
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{fromOrderId && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Z objednávky
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{!isEdit && fromOrderId && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
Z objednávky
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1866,37 +1933,19 @@ export default function InvoiceDetail() {
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Základní údaje
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Číslo faktury">
|
||||
<TextField
|
||||
value={invoiceNumber}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vystavil">
|
||||
<TextField
|
||||
value={form.issued_by}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
{/* Číslo faktury and Vystavil are not form fields anymore: the
|
||||
number lives in the page header (deferred numbering — "Koncept"
|
||||
until finalize) and issued_by is auto-filled from the logged-in
|
||||
user and still submitted with the payload (the PDF prints it). */}
|
||||
<Field label="Odběratel" error={errors.customer_id} required>
|
||||
<CustomerPicker
|
||||
customers={customers}
|
||||
value={form.customer_id}
|
||||
onChange={selectCustomer}
|
||||
error={errors.customer_id}
|
||||
placeholder="Hledat zákazníka (název, IČ)..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Text fakturace (na PDF)">
|
||||
<TextField
|
||||
@@ -2242,16 +2291,26 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{/* Rich-text PDF sections — printed after the items (like orders) */}
|
||||
<SectionsEditor
|
||||
sections={sections}
|
||||
onChange={setSections}
|
||||
readOnly={false}
|
||||
title="Obsah"
|
||||
language={form.language}
|
||||
/>
|
||||
|
||||
{/* Internal notes */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Veřejné poznámky na faktuře
|
||||
</Typography>
|
||||
<RichEditor
|
||||
value={form.notes}
|
||||
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))}
|
||||
placeholder="Poznámky zobrazené na faktuře..."
|
||||
/>
|
||||
<Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
|
||||
<RichEditor
|
||||
value={form.internal_notes}
|
||||
onChange={(val) =>
|
||||
setForm((prev) => ({ ...prev, internal_notes: val }))
|
||||
}
|
||||
placeholder="Interní poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user