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:
BOHA
2026-06-10 19:02:37 +02:00
parent ffae1a4e74
commit 40a859f5e1
12 changed files with 991 additions and 548 deletions

View File

@@ -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;

View File

@@ -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>