Files
app/src/admin/pages/InvoiceDetail.tsx
2026-06-16 20:20:51 +02:00

2481 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
useNavigate,
useSearchParams,
useParams,
Link as RouterLink,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { useApiMutation } from "../lib/queries/mutations";
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 CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import {
DndContext,
closestCenter,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import {
restrictToVerticalAxis,
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import apiFetch from "../utils/api";
import { companySettingsOptions } from "../lib/queries/settings";
import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import {
formatCurrency,
formatDate,
numberOr,
restoreQuillBlankLines,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
Card,
TextField,
Select,
DateField,
Field,
StatusChip,
CheckboxField,
ConfirmDialog,
EmptyState,
LoadingState,
PageEnter,
RichTextView,
CustomerPicker,
headerActionsSx,
} from "../ui";
import {
INVOICE_STATUS,
statusLabel,
statusColor,
documentNumberLabel,
} from "../lib/documentStatus";
const API_BASE = "/api/admin";
/**
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
* used. We build the Date from the local year/month/day parts (so it's a local
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
* it back with local getters — never via `toISOString()`, which would shift a
* day during the evening Prague window. See utils/formatters.todayLocalStr.
*/
function addDaysLocalStr(days: number, base?: string): string {
let y: number, m: number, d: number;
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
if (parts) {
y = Number(parts[1]);
m = Number(parts[2]) - 1;
d = Number(parts[3]);
} else {
const now = new Date();
y = now.getFullYear();
m = now.getMonth();
d = now.getDate();
}
const result = new Date(y, m, d + days);
const mm = String(result.getMonth() + 1).padStart(2, "0");
const dd = String(result.getDate()).padStart(2, "0");
return `${result.getFullYear()}-${mm}-${dd}`;
}
const TRANSITION_LABELS: Record<string, string> = { paid: "Zaplaceno" };
// The live (finalized) status for an invoice — assigning it (on create or on
// finalizing a draft) makes the backend consume the official invoice number.
const LIVE_STATUS = "issued";
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const FileIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
const DragIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
);
const RemoveIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
interface InvoiceItem {
id?: number;
_key: string;
description: string;
item_description: string;
// Held as the raw typed string while editing so the field can be cleared
// (empty renders fine in a type="number" input). Coerced via Number(x) || 0
// only where used (live totals + save payload). vat_rate stays numeric: it
// is edited via a Select, never a free-text number input.
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). */
discount: string | number;
vat_rate: number;
}
/**
* Discount input styling: centered, native number spinners hidden so a 3-digit
* value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one invoice line after its per-item percentage discount. */
const invoiceLineNet = (item: {
quantity: string | number;
unit_price: string | number;
discount?: string | number | null;
}): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
order_id: number | null;
issue_date: string;
due_date: string;
tax_date: string;
currency: string;
apply_vat: number;
vat_rate: number;
payment_method: string;
constant_symbol: string;
issued_by: string;
billing_text: string;
internal_notes: string;
language: string;
bank_account_id: number | string;
bank_name: string;
bank_swift: string;
bank_iban: string;
bank_account: string;
}
// Sortable row for create mode
function SortableInvoiceRow({
item,
index,
currency,
apply_vat,
vatOptions,
onUpdate,
onRemove,
canDelete,
}: {
item: InvoiceItem;
index: number;
currency: string;
apply_vat: boolean;
vatOptions: { value: number; label: string }[];
onUpdate: (
index: number,
field: keyof InvoiceItem,
value: string | number,
) => void;
onRemove: (index: number) => void;
canDelete: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item._key });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
background: isDragging ? "var(--mui-palette-action-hover)" : undefined,
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal = invoiceLineNet(item);
if (isMobile) {
return (
<Box
ref={setNodeRef}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 1.25,
bgcolor: "background.paper",
...style,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
>
{DragIcon}
</IconButton>
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Položka {index + 1}
</Typography>
<Box sx={{ flex: 1 }} />
{canDelete && (
<IconButton
color="error"
size="small"
onClick={() => onRemove(index)}
title="Odebrat"
aria-label="Odebrat"
>
{RemoveIcon}
</IconButton>
)}
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Popis položky..."
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný"
multiline
minRows={2}
maxRows={16}
sx={{ "& textarea": { resize: "vertical" } }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate(index, "unit", e.target.value)}
placeholder="ks"
/>
<TextField
label="Jedn. cena"
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
/>
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
{apply_vat && (
<Select
label="DPH"
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
options={vatOptions.map((o) => ({
value: String(o.value),
label: o.label,
}))}
/>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
alignItems: "baseline",
gap: 1,
pt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Celkem
</Typography>
<Typography
sx={{ fontFamily: "'DM Mono', Menlo, monospace", fontWeight: 600 }}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
);
}
return (
<TableRow ref={setNodeRef} sx={style}>
<TableCell sx={{ width: "2rem", px: 0.5 }}>
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary", touchAction: "none" }}
>
{DragIcon}
</IconButton>
</TableCell>
<TableCell
align="center"
sx={{ color: "text.secondary", fontWeight: 500 }}
>
{index + 1}
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<TextField
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Popis položky..."
sx={{ "& input": { fontWeight: 500 } }}
/>
<TextField
value={item.item_description}
onChange={(e) =>
onUpdate(index, "item_description", e.target.value)
}
placeholder="Podrobný popis (volitelný)"
multiline
minRows={2}
maxRows={16}
sx={{
"& textarea": {
fontSize: "0.8rem",
opacity: 0.8,
resize: "vertical",
},
}}
/>
</Box>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate(index, "quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
value={item.unit}
onChange={(e) => onUpdate(index, "unit", e.target.value)}
placeholder="ks"
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
</TableCell>
{apply_vat ? (
<TableCell>
<Select
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
sx={{ minWidth: "4.5rem" }}
options={vatOptions.map((o) => ({
value: String(o.value),
label: o.label,
}))}
/>
</TableCell>
) : null}
<TableCell
align="right"
sx={{
fontWeight: 600,
whiteSpace: "nowrap",
fontFamily: "'DM Mono', Menlo, monospace",
}}
>
{formatCurrency(lineTotal, currency)}
</TableCell>
<TableCell>
{canDelete && (
<IconButton
color="error"
size="small"
onClick={() => onRemove(index)}
title="Odebrat"
aria-label="Odebrat"
>
{RemoveIcon}
</IconButton>
)}
</TableCell>
</TableRow>
);
}
export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const keyCounterRef = useRef(1);
const emptyItem = useCallback(
(): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
}),
[],
);
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
// pointer events and its 5px distance constraint fires before the
// TouchSensor's long-press delay — the browser then claims the gesture for
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
// TouchSensor handles touch via long-press; the drag handles additionally
// carry touch-action: none so the browser never starts a scroll from them.
const dndSensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 300, tolerance: 8 },
}),
useSensor(KeyboardSensor),
);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const alert = useAlert();
const { hasPermission, user } = useAuth();
// ─── Create mode state ───
const rawOrderId = searchParams.get("fromOrder");
const fromOrderId =
!isEdit && rawOrderId && /^\d+$/.test(rawOrderId) ? rawOrderId : null;
const [form, setForm] = useState<InvoiceForm>(() => ({
customer_id: null,
customer_name: "",
order_id: fromOrderId ? Number(fromOrderId) : null,
issue_date: todayLocalStr(),
due_date: addDaysLocalStr(14),
tax_date: todayLocalStr(),
currency: "CZK",
apply_vat: 1,
vat_rate: 21,
payment_method: "Příkazem",
constant_symbol: "0308",
issued_by: user?.fullName || "",
billing_text: "",
internal_notes: "",
language: "cs",
bank_account_id: "",
bank_name: "",
bank_swift: "",
bank_iban: "",
bank_account: "",
}));
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 [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [items, setItems] = useState<InvoiceItem[]>(() => [
{
_key: "inv-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | LIVE_STATUS | "save"), so only the
// clicked button shows its spinner — `saving` still disables all of them to
// prevent a concurrent double-submit.
const [savingAction, setSavingAction] = useState<string | null>(null);
const [dataReady, setDataReady] = useState(false);
const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null);
const companySettings = useQuery(companySettingsOptions()).data;
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const vatOptions = (
companySettings?.available_vat_rates || [0, 10, 12, 15, 21]
).map((v) => ({
value: v,
label: `${v}%`,
}));
// ─── TanStack Query ───
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
// Read-only view: show the Sleva column only when some line carries a discount.
const hasItemDiscount = (invoice?.items ?? []).some(
(it) => Number(it.discount) > 0,
);
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
`${API_BASE}/invoices/next-number`,
).then((d) => d?.next_number || d?.number || ""),
enabled: !isEdit,
});
const orderDataQuery = useQuery({
queryKey: ["invoices", "order-data", fromOrderId],
queryFn: () =>
jsonQuery<Record<string, unknown>>(
`${API_BASE}/invoices/order-data/${fromOrderId}`,
),
enabled: !!fromOrderId,
});
// ─── Edit mode state ───
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [pdfLoading, setPdfLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeouts.forEach(clearTimeout);
};
}, []);
// After an in-place draft→issued finalize, the one-shot hydration effect below
// (gated by dataReady) won't re-run, so the freshly-assigned number from the
// refetched query is mirrored into the local field here (matches the
// issued-order page's po_number resync).
useEffect(() => {
if (
isEdit &&
invoice?.invoice_number &&
invoice.invoice_number !== invoiceNumber
) {
setInvoiceNumber(invoice.invoice_number);
}
}, [isEdit, invoice?.invoice_number, invoiceNumber]);
// ─── Sync query data to form state ───
// Edit mode: populate form from invoice data
useEffect(() => {
if (!isEdit || dataReady) return;
if (
invoiceQuery.isLoading ||
bankAccountsQuery.isLoading ||
customersQuery.isLoading
)
return;
if (!invoiceQuery.data) return;
const inv = invoiceQuery.data;
// Match bank account from invoice's bank details
let matchedBankId: number | string = "";
const bankData = bankAccountsQuery.data ?? [];
const match = bankData.find(
(b) =>
(inv.bank_iban && b.iban === inv.bank_iban) ||
(inv.bank_account && b.account_number === inv.bank_account),
);
if (match) matchedBankId = match.id;
const formData: InvoiceForm = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: normalizeDateStr(inv.issue_date),
due_date: normalizeDateStr(inv.due_date),
tax_date: normalizeDateStr(inv.tax_date),
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: numberOr(inv.vat_rate, 21),
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "",
internal_notes: inv.internal_notes || "",
language: inv.language || "cs",
bank_account_id: matchedBankId,
bank_name: inv.bank_name || "",
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
};
setForm(formData);
setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
const issue = new Date(inv.issue_date);
const due = new Date(inv.due_date);
const diffDays = Math.round(
(due.getTime() - issue.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays > 0 && diffDays <= 60) setDueDays(diffDays);
}
// Populate items from existing invoice
const invItems = inv.items;
const mappedItems =
invItems && invItems.length > 0
? invItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id,
description: item.description || "",
item_description: item.item_description || "",
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
// from the invoice rate corrupted mixed-rate invoices on re-save.
vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)),
}))
: [];
if (mappedItems.length > 0) {
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);
const mappedCustomFields = inv.selected_custom_fields ?? [];
setSelectedCustomFields(mappedCustomFields);
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
selectedCustomFields: mappedCustomFields,
});
setDataReady(true);
}, [
isEdit,
dataReady,
invoiceQuery.isLoading,
invoiceQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
]);
// Create mode: populate form from query data
useEffect(() => {
if (isEdit || dataReady) return;
if (
nextNumberQuery.isLoading ||
bankAccountsQuery.isLoading ||
customersQuery.isLoading
)
return;
if (fromOrderId && orderDataQuery.isLoading) return;
// Set invoice number from the server's next-number.
if (nextNumberQuery.data) {
setInvoiceNumber(nextNumberQuery.data);
}
// Set default bank account
const defaultAcc = bankAccounts.find((a) => a.is_default);
if (defaultAcc) {
setForm((prev) => ({
...prev,
bank_account_id: defaultAcc.id,
bank_name: defaultAcc.bank_name || "",
bank_swift: defaultAcc.bic || "",
bank_iban: defaultAcc.iban || "",
bank_account: defaultAcc.account_number || "",
}));
}
// Pre-fill from order. Orders no longer carry VAT (not tax documents) —
// the invoice decides its own VAT: default rate from company settings,
// apply_vat on.
if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data;
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,
customer_name: (order.customer_name as string) || "",
order_id: order.id as number,
currency:
(order.currency as string) ||
companySettings?.default_currency ||
"CZK",
apply_vat: 1,
vat_rate: vatRate,
}));
const orderItems = order.items as Record<string, unknown>[] | undefined;
if (orderItems && orderItems.length > 0) {
setItems(
orderItems.map((item) => ({
_key: `inv-${++keyCounterRef.current}`,
description: (item.description as string) || "",
item_description: (item.item_description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
vat_rate: vatRate,
})),
);
}
}
setDataReady(true);
}, [
isEdit,
dataReady,
nextNumberQuery.isLoading,
nextNumberQuery.data,
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
fromOrderId,
orderDataQuery.isLoading,
orderDataQuery.data,
companySettings,
bankAccounts,
]);
// Capture initial snapshot for dirty-checking once data sync completes.
// 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,
sections,
selectedCustomFields,
});
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections, selectedCustomFields }) !==
initialSnapshotRef.current
);
}, [form, items, sections, selectedCustomFields]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const computedDueDate = useMemo(() => {
if (!form.issue_date) return "";
return addDaysLocalStr(dueDays, form.issue_date);
}, [form.issue_date, dueDays]);
const selectBankAccount = (accountId: string) => {
const acc = bankAccounts.find((a) => a.id === Number(accountId));
if (acc) {
setForm((prev) => ({
...prev,
bank_account_id: acc.id,
bank_name: acc.bank_name || "",
bank_swift: acc.bic || "",
bank_iban: acc.iban || "",
bank_account: acc.account_number || "",
}));
} else {
setForm((prev) => ({
...prev,
bank_account_id: "",
bank_name: "",
bank_swift: "",
bank_iban: "",
bank_account: "",
}));
}
};
const selectCustomer = (id: number | null) => {
const customer = id != null ? customers.find((c) => c.id === id) : null;
setForm((prev) => ({
...prev,
customer_id: id,
customer_name: customer?.name ?? "",
}));
setErrors((prev) => ({ ...prev, customer_id: "" }));
};
// ─── Create mode: items management ───
const updateItem = (
index: number,
field: keyof InvoiceItem,
value: string | number,
) => {
setItems((prev) =>
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
);
};
const addItem = () => setItems((prev) => [...prev, emptyItem()]);
const removeItem = (index: number) => {
if (items.length <= 1) return;
setItems((prev) => prev.filter((_, i) => i !== index));
};
const handleCreateDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex((i) => i._key === String(active.id));
const newIndex = prev.findIndex((i) => i._key === String(over.id));
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
};
// ─── Create mode: totals ───
const createTotals = useMemo(() => {
let subtotal = 0;
const vatByRate: Record<number, number> = {};
items.forEach((item) => {
const lineTotal = invoiceLineNet(item);
subtotal += lineTotal;
if (form.apply_vat) {
const rate = Number(item.vat_rate) || 0;
if (!vatByRate[rate]) vatByRate[rate] = 0;
vatByRate[rate] += (lineTotal * rate) / 100;
}
});
const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0);
return { subtotal, vatByRate, totalVat, total: subtotal + totalVat };
}, [items, form.apply_vat]);
// ─── Create/Edit mode: submit ───
const saveMutation = useApiMutation<
Record<string, unknown>,
{ invoice_id: number }
>({
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["invoices", "orders"],
onSuccess: (data) => {
const invoiceId = isEdit ? Number(id) : data.invoice_id;
// PDF binary generation — KEEP as raw apiFetch
void apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
},
});
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "PUT",
invalidate: ["invoices", "orders"],
});
const invoiceDeleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "DELETE",
invalidate: ["invoices", "orders"],
});
const handleCreateSubmit = async (targetStatus?: string) => {
const newErrors: Record<string, string> = {};
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
if (!form.issue_date) newErrors.issue_date = "Zadejte datum";
if (!form.tax_date) newErrors.tax_date = "Zadejte datum";
if (!form.bank_account_id)
newErrors.bank_account_id = "Vyberte bankovní účet";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
setSavingAction(targetStatus ?? "save");
try {
const payload: Record<string, unknown> = {
...form,
due_date: computedDueDate || form.due_date,
items: items
.filter((i) => i.description.trim())
.map((item, i) => ({
...item,
// Coerce the raw typed strings back to numbers so an empty/partial
// field never reaches the server as NaN (mirrors createTotals).
quantity: Number(item.quantity) || 0,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// 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
// its current status.
if (targetStatus) payload.status = targetStatus;
// The invoice number is NOT user-editable for drafts (it is NULL until
// finalized). Send it back only when editing an already-numbered invoice.
if (isEdit && invoice?.status !== "draft")
payload.invoice_number = invoiceNumber;
const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
} catch (e) {
alert.error(
e instanceof Error
? e.message
: isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu",
);
} finally {
setSaving(false);
setSavingAction(null);
}
};
// Native form submit (e.g. Enter): route to the right save path. Create →
// finalize live; editing a draft → save draft (the visible primary button);
// editing a live invoice → plain save (no status change).
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!isEdit) void handleCreateSubmit(LIVE_STATUS);
else if (invoice?.status === "draft") void handleCreateSubmit("draft");
else void handleCreateSubmit();
};
// ─── Edit mode: status change ───
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
};
// ─── Edit mode: PDF export ───
const handleViewPdf = async (_lang = "cs") => {
const newWindow = window.open("", "_blank");
setPdfLoading(true);
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}/file`);
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — uložte fakturu pro vygenerování");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
} finally {
setPdfLoading(false);
}
};
// ─── Edit mode: delete ───
const handleDelete = async () => {
setDeleting(true);
try {
await invoiceDeleteMutation.mutateAsync(undefined);
alert.success("Faktura byla smazána");
navigate("/invoices");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
}
};
// ─── Permission checks ───
if (!isEdit && !hasPermission("invoices.create")) return <Forbidden />;
if (isEdit && !hasPermission("invoices.view")) return <Forbidden />;
// ═══════════════════════════════════════════════════════════
// PAID INVOICE — read-only view
// ═══════════════════════════════════════════════════════════
const isPaid = isEdit && invoice?.status === "paid";
if (isEdit && !invoice) return null;
if (isPaid && invoice) {
if (!dataReady) {
return <LoadingState />;
}
return (
<PageEnter>
{/* Header */}
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
{/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/invoices?tab=issued"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
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)}
color={statusColor(INVOICE_STATUS, invoice.status)}
/>
</Box>
</Box>
<Box sx={headerActionsSx}>
{hasPermission("invoices.view") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
variant="outlined"
color="inherit"
disabled={pdfLoading}
startIcon={
pdfLoading ? (
<CircularProgress size={16} color="inherit" />
) : (
FileIcon
)
}
>
Zobrazit fakturu
</Button>
)}
{/* A paid invoice is a settled financial record — never deletable
from the UI. (Backend rejection of deleting a paid invoice is a
separate hardening, out of scope here.) */}
{hasPermission("invoices.delete") &&
invoice.status !== "paid" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</Box>
</Box>
</Box>
{/* Info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Informace
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
mb: 2,
}}
>
<Field label="Zákazník">
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{invoice.customer_name || "—"}
</Typography>
{invoice.customer && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.25 }}
>
{invoice.customer.company_id &&
`IČ: ${invoice.customer.company_id}`}
{invoice.customer.vat_id &&
` · DIČ: ${invoice.customer.vat_id}`}
</Typography>
)}
</Field>
<Field label="Objednávka">
<Typography variant="body2">
{invoice.order_id ? (
<Box
component={RouterLink}
to={`/orders/${invoice.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{invoice.order_number}
</Box>
) : (
"—"
)}
</Typography>
</Field>
<Field label="Měna">
<Typography variant="body2">{invoice.currency}</Typography>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
mb: 2,
}}
>
<Field label="Datum vystavení">
<Typography variant="body2">
{formatDate(invoice.issue_date)}
</Typography>
</Field>
<Field label="Datum splatnosti">
<Typography variant="body2">
{formatDate(invoice.due_date)}
</Typography>
</Field>
<Field label="DÚZP">
<Typography variant="body2">
{formatDate(invoice.tax_date)}
</Typography>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Forma úhrady">
<Typography variant="body2">{invoice.payment_method}</Typography>
</Field>
<Field label="Variabilní symbol">
<Typography variant="body2">{invoice.invoice_number}</Typography>
</Field>
</Box>
{invoice.paid_date && (
<Box sx={{ mt: 2 }}>
<Field label="Datum úhrady">
<Typography
variant="body2"
sx={{ fontWeight: 500, color: "success.main" }}
>
{formatDate(invoice.paid_date)}
</Typography>
</Field>
</Box>
)}
</Card>
{/* Items (read-only) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{invoice.items && invoice.items.length > 0 ? (
isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{invoice.items.map((item, index) => {
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
return (
<Box
key={item.id || index}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 0.75,
}}
>
<Box sx={{ fontWeight: 600 }}>
{item.description || "—"}
{item.item_description && (
<Typography
variant="caption"
sx={{
display: "block",
opacity: 0.8,
fontWeight: 400,
}}
>
{item.item_description}
</Typography>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
fontSize: ".85rem",
}}
>
<Typography variant="caption" color="text.secondary">
Množství
</Typography>
<span>
{item.quantity} {item.unit}
</span>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
fontSize: ".85rem",
}}
>
<Typography variant="caption" color="text.secondary">
Jedn. cena
</Typography>
<Box
component="span"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(item.unit_price, invoice.currency)}
</Box>
</Box>
{Number(invoice.apply_vat) ? (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
fontSize: ".85rem",
}}
>
<Typography variant="caption" color="text.secondary">
%DPH
</Typography>
<span>{Number(item.vat_rate)}%</span>
</Box>
) : null}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Celkem
</Typography>
<Box
component="span"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
{formatCurrency(
lineSubtotal + lineVat,
invoice.currency,
)}
</Box>
</Box>
</Box>
);
})}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{ "& td, & th": { borderColor: "divider" } }}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2.5rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{hasItemDiscount && (
<TableCell sx={{ width: "4rem" }} align="center">
Sleva %
</TableCell>
)}
<TableCell sx={{ width: "4rem" }} align="center">
DPH
</TableCell>
<TableCell sx={{ width: "9rem" }} align="right">
Celkem
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoice.items.map((item, index) => {
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
return (
<TableRow key={item.id || index}>
<TableCell
align="center"
sx={{ color: "text.secondary", fontWeight: 500 }}
>
{index + 1}
</TableCell>
<TableCell sx={{ fontWeight: 500 }}>
{item.description || "—"}
{item.item_description && (
<Typography
variant="caption"
sx={{
display: "block",
opacity: 0.8,
mt: "2px",
}}
>
{item.item_description}
</Typography>
)}
</TableCell>
<TableCell align="center">
{item.quantity}{" "}
{item.unit && (
<Box
component="span"
sx={{ color: "text.secondary" }}
>
{item.unit}
</Box>
)}
</TableCell>
<TableCell align="center">
{item.unit || "—"}
</TableCell>
<TableCell
align="right"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
}}
>
{formatCurrency(item.unit_price, invoice.currency)}
</TableCell>
{hasItemDiscount && (
<TableCell align="center">
{Number(item.discount) > 0
? `${Number(item.discount)} %`
: "—"}
</TableCell>
)}
<TableCell align="center">
{Number(invoice.apply_vat)
? Number(item.vat_rate)
: 0}
%
</TableCell>
<TableCell
align="right"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
{formatCurrency(
lineSubtotal + lineVat,
invoice.currency,
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)
) : (
<EmptyState title="Žádné položky." />
)}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(createTotals.subtotal, invoice.currency)}
</Typography>
</Box>
{Number(invoice.apply_vat) > 0 &&
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
<Box
key={rate}
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography variant="body2" color="text.secondary">
DPH {rate}%:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(amount, invoice.currency)}
</Typography>
</Box>
))}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem k úhradě:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(createTotals.total, invoice.currency)}
</Typography>
</Box>
</Box>
</Card>
{/* 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(
restoreQuillBlankLines(s.content),
),
}}
/>
)}
</Box>
))}
</Box>
</Card>
)}
{/* Internal notes (read-only, never printed) */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Interní poznámky
</Typography>
{invoice.internal_notes &&
invoice.internal_notes.trim() &&
invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
restoreQuillBlankLines(invoice.internal_notes),
),
}}
/>
) : (
<Typography variant="body2" color="text.secondary">
Žádné poznámky.
</Typography>
)}
</Card>
{/* Delete confirm */}
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
</PageEnter>
);
}
// ═══════════════════════════════════════════════════════════
// CREATE MODE + EDIT (not paid) — shared form
// ═══════════════════════════════════════════════════════════
if (!dataReady) {
return <LoadingState />;
}
return (
<PageEnter>
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
{/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/invoices?tab=issued"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box>
<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>
{!isEdit && fromOrderId && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Z objednávky
</Typography>
)}
</Box>
</Box>
<Box sx={headerActionsSx}>
{/* Drafts have no number → /file 404s; hide the button like
OfferDetail/IssuedOrderDetail do. */}
{isEdit &&
invoice &&
invoice.invoice_number &&
hasPermission("invoices.view") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
variant="outlined"
color="inherit"
disabled={pdfLoading}
startIcon={
pdfLoading ? (
<CircularProgress size={16} color="inherit" />
) : (
FileIcon
)
}
>
Zobrazit fakturu
</Button>
)}
{/* ── Create mode: two-button save ── */}
{!isEdit && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => handleCreateSubmit("draft")}
disabled={saving}
>
{savingAction === "draft" ? (
<CircularProgress size={16} color="inherit" />
) : (
"Uložit koncept"
)}
</Button>
<Button
onClick={() => handleCreateSubmit(LIVE_STATUS)}
disabled={saving}
>
{savingAction === LIVE_STATUS ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Vytvořit"
)}
</Button>
</>
)}
{/* ── Edit mode: draft → save-draft + finalize ── */}
{isEdit && invoice && invoice.status === "draft" && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => handleCreateSubmit("draft")}
disabled={saving}
>
{savingAction === "draft" ? (
<CircularProgress size={16} color="inherit" />
) : (
"Uložit koncept"
)}
</Button>
{hasPermission("invoices.edit") && (
<Button
onClick={() => handleCreateSubmit(LIVE_STATUS)}
disabled={saving}
>
{savingAction === LIVE_STATUS ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Vystavit"
)}
</Button>
)}
{/* A draft is deleted rather than cancelled. */}
{hasPermission("invoices.delete") && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</>
)}
{/* ── Edit mode: live (non-draft) doc — unchanged ── */}
{isEdit && invoice && invoice.status !== "draft" && (
<>
<Button onClick={() => handleCreateSubmit()} disabled={saving}>
{savingAction === "save" ? (
<>
<CircularProgress
size={16}
color="inherit"
sx={{ mr: 1 }}
/>
Ukládání...
</>
) : (
"Uložit"
)}
</Button>
{hasPermission("invoices.edit") &&
invoice.valid_transitions?.map((status) => (
<Button
key={status}
onClick={() => setStatusConfirm({ show: true, status })}
variant={
status === "cancelled" || status === "stornovana"
? "outlined"
: "contained"
}
color={
status === "cancelled" || status === "stornovana"
? "error"
: "primary"
}
disabled={statusChanging === status}
>
{statusChanging === status ? (
<CircularProgress size={14} color="inherit" />
) : (
TRANSITION_LABELS[status] || status
)}
</Button>
))}
{/* A paid invoice is a settled financial record — never
deletable from the UI. A paid invoice never actually reaches
this branch (it renders the read-only view above), but the
guard documents the invariant explicitly. */}
{hasPermission("invoices.delete") &&
invoice.status !== "paid" && (
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
</>
)}
</Box>
</Box>
</Box>
<form onSubmit={handleFormSubmit}>
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
{/* Čí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
value={form.billing_text}
onChange={(e) =>
setForm((prev) => ({
...prev,
billing_text: e.target.value,
}))
}
placeholder="Fakturujeme Vám za: (ponechte prázdné pro výchozí)"
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Datum vystavení" error={errors.issue_date} required>
<DateField
value={form.issue_date}
onChange={(val) => {
setForm((prev) => ({ ...prev, issue_date: val }));
setErrors((prev) => ({ ...prev, issue_date: "" }));
}}
/>
</Field>
<Field label="Splatnost (dny)">
<Select
value={String(dueDays)}
onChange={(val) => setDueDays(Number(val))}
options={Array.from({ length: 60 }, (_, i) => i + 1).map(
(n) => ({ value: String(n), label: String(n) }),
)}
/>
{computedDueDate && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.5 }}
>
Splatnost:{" "}
{new Date(computedDueDate).toLocaleDateString("cs-CZ")}
</Typography>
)}
</Field>
<Field label="DÚZP" error={errors.tax_date} required>
<DateField
value={form.tax_date}
onChange={(val) => {
setForm((prev) => ({ ...prev, tax_date: val }));
setErrors((prev) => ({ ...prev, tax_date: "" }));
}}
/>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: 2,
}}
>
<Field label="Forma úhrady">
<Select
value={form.payment_method}
onChange={(val) =>
setForm((prev) => ({ ...prev, payment_method: val }))
}
options={[
{ value: "Příkazem", label: "Příkazem" },
{ value: "Hotově", label: "Hotově" },
{ value: "Dobírka", label: "Dobírka" },
]}
/>
</Field>
<Field label="Měna">
<Select
value={form.currency}
onChange={(val) =>
setForm((prev) => ({ ...prev, currency: val }))
}
options={(
companySettings?.available_currencies || [
"CZK",
"EUR",
"USD",
"GBP",
]
).map((c) => ({ value: c, label: c }))}
/>
</Field>
<Field label="Jazyk faktury">
<Select
value={form.language}
onChange={(val) =>
setForm((prev) => ({ ...prev, language: val }))
}
options={[
{ value: "cs", label: "Čeština" },
{ value: "en", label: "English" },
]}
/>
</Field>
{/* Document-default VAT rate. Presentational only — it writes the
already-existing `form.vat_rate` (used to seed new line rows /
as the per-line fallback). Totals are computed per-line from
`item.vat_rate`, so changing this does NOT alter computed VAT. */}
<Field label="Sazba DPH">
<Select
value={String(form.vat_rate)}
disabled={!form.apply_vat}
onChange={(val) =>
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
}
options={vatOptions.map((o) => ({
value: String(o.value),
label: o.label,
}))}
/>
</Field>
<Field label="DPH">
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
<CheckboxField
label="Uplatnit DPH"
checked={!!form.apply_vat}
onChange={(v) =>
setForm((prev) => ({
...prev,
apply_vat: v ? 1 : 0,
}))
}
/>
</Box>
</Field>
</Box>
<Field label="Bankovní účet" error={errors.bank_account_id} required>
<Select
value={String(form.bank_account_id)}
onChange={(val) => {
selectBankAccount(val);
setErrors((prev) => ({ ...prev, bank_account_id: "" }));
}}
options={[
{
value: "",
label: `— Vyberte účet —`,
},
...bankAccounts.map((acc) => ({
value: String(acc.id),
label: `${acc.account_name}${
acc.account_number ? ` (${acc.account_number})` : ""
}${acc.is_default ? " ★" : ""}`,
})),
]}
/>
</Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={false}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items */}
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 2,
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography variant="caption" color="error">
{errors.items}
</Typography>
)}
</Box>
<Button
type="button"
onClick={addItem}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat položku
</Button>
</Box>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={handleCreateDragEnd}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
{isMobile ? (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{items.map((item, index) => (
<SortableInvoiceRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
<TableCell sx={{ width: "2.5rem" }} />
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableInvoiceRow
key={item._key}
item={item}
index={index}
currency={form.currency}
apply_vat={!!form.apply_vat}
vatOptions={vatOptions}
onUpdate={updateItem}
onRemove={removeItem}
canDelete={items.length > 1}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
</SortableContext>
</DndContext>
{/* Totals */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(createTotals.subtotal, form.currency)}
</Typography>
</Box>
{form.apply_vat &&
Object.entries(createTotals.vatByRate).map(([rate, amount]) => (
<Box
key={rate}
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography variant="body2" color="text.secondary">
DPH {rate}%:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(amount, form.currency)}
</Typography>
</Box>
))}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem k úhradě:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(createTotals.total, form.currency)}
</Typography>
</Box>
</Box>
</Card>
{/* 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 }}>
<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>
{/* Status change confirm (edit mode only) */}
{isEdit && invoice && (
<>
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav faktury"
message={`Opravdu chcete změnit stav faktury "${invoice.invoice_number}" na "${statusLabel(INVOICE_STATUS, statusConfirm.status)}"?`}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
cancelText="Zrušit"
/>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${invoice.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
</>
)}
</PageEnter>
);
}