Files
app/src/admin/pages/Invoices.tsx
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

876 lines
23 KiB
TypeScript

import { useState, useEffect, useRef, lazy, Suspense } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link as RouterLink, useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import {
formatCurrency,
formatDate,
czechPlural,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import useTableSort from "../hooks/useTableSort";
import {
invoiceListOptions,
invoiceStatsOptions,
type Invoice,
type CurrencyAmount,
} from "../lib/queries/invoices";
import {
Button,
Card,
DataTable,
Pagination,
ConfirmDialog,
TextField,
StatusChip,
StatCard,
Alert,
EmptyState,
LoadingState,
FilterBar,
Tabs,
PageHeader,
PageEnter,
type DataColumn,
type TabDef,
type StatCardColor,
} from "../ui";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_invoice_draft";
const MONTH_NAMES = [
"leden",
"únor",
"březen",
"duben",
"květen",
"červen",
"červenec",
"srpen",
"září",
"říjen",
"listopad",
"prosinec",
];
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
}
function formatCzkWithDetail(
amounts: CurrencyAmount[],
totalCzk: number | null | undefined,
): { value: string; detail: string | null } {
if (!Array.isArray(amounts) || amounts.length === 0)
return { value: "0 Kč", detail: null };
const hasForeign = amounts.some((a) => a.currency !== "CZK");
if (hasForeign && totalCzk != null) {
return {
value: formatCurrency(totalCzk, "CZK"),
detail: formatMultiCurrency(amounts),
};
}
return { value: formatMultiCurrency(amounts), detail: null };
}
const STATUS_LABELS: Record<string, string> = {
issued: "Vystavena",
paid: "Zaplacena",
overdue: "Po splatnosti",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "warning" | "error"
> = {
issued: "warning",
paid: "success",
overdue: "error",
};
const STATUS_FILTERS = [
{ value: "", label: "Vše" },
{ value: "issued", label: "Vystavené" },
{ value: "paid", label: "Zaplacené" },
{ value: "overdue", label: "Po splatnosti" },
];
interface DraftData {
form: Record<string, unknown>;
items: Record<string, unknown>[];
savedAt?: string;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const UploadIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
const ChevronLeftIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="15 18 9 12 15 6" />
</svg>
);
const ChevronRightIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="9 18 15 12 9 6" />
</svg>
);
const ViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const PdfIcon = (
<svg
width="18"
height="18"
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" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Invoices() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab =
searchParams.get("tab") === "received" ? "received" : "issued";
const setActiveTab = (tab: string) =>
setSearchParams({ tab }, { replace: true });
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
const { sort, order, handleSort } = useTableSort("invoice_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const now = new Date();
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear());
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear));
const stats = statsQuery.data ?? null;
const prevMonth = () => {
if (statsMonth === 1) {
setStatsMonth(12);
setStatsYear((y) => y - 1);
} else {
setStatsMonth((m) => m - 1);
}
};
const nextMonth = () => {
if (isCurrentMonth) return;
if (statsMonth === 12) {
setStatsMonth(1);
setStatsYear((y) => y + 1);
} else {
setStatsMonth((m) => m + 1);
}
};
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: Invoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [draft, setDraft] = useState<DraftData | null>(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DraftData;
if (parsed && parsed.form && Array.isArray(parsed.items)) return parsed;
} catch {
/* ignore */
}
return null;
});
const discardDraft = () => {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
}
setDraft(null);
};
const queryClient = useQueryClient();
const {
items: invoices,
pagination,
isPending: initialLoad,
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
sort,
order,
page,
month: statsMonth,
year: statsYear,
status: statusFilter || undefined,
}),
);
if (!hasPermission("invoices.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.invoice) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/invoices/${deleteConfirm.invoice.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, invoice: null });
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
const toggleStatus = async (inv: Invoice) => {
if (inv.status === "paid") return;
try {
const res = await apiFetch(`${API_BASE}/invoices/${inv.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "paid" }),
});
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako zaplacená");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
}
};
const handlePdf = async (inv: Invoice) => {
if (pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(inv.id);
try {
const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`);
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji");
return;
}
const blob = await response.blob();
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
alert.error("Chyba při generování PDF");
} finally {
setPdfLoading(null);
}
};
if (initialLoad) {
return <LoadingState />;
}
const total = pagination?.total ?? invoices.length;
const subtitle = `${total} ${czechPlural(
total,
"faktura",
"faktury",
"faktur",
)}`;
const statusTabs: TabDef[] = STATUS_FILTERS.map((f) => ({
value: f.value,
label: f.label,
}));
// KPI cards — stat math preserved verbatim.
const renderKpi = () => {
if (statsQuery.isPending && !stats) {
return <LoadingState />;
}
if (!stats) return null;
const paid = formatCzkWithDetail(stats.paid_month, stats.paid_month_czk);
const wait = formatCzkWithDetail(stats.awaiting, stats.awaiting_czk);
const over = formatCzkWithDetail(stats.overdue, stats.overdue_czk);
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk);
const countFooter = (count: number, zero: string) =>
count > 0
? `${count} ${czechPlural(count, "faktura", "faktury", "faktur")}`
: zero;
const cards: {
label: string;
value: string;
footer: string;
color: StatCardColor;
}[] = [
{
label: `Uhrazeno (${MONTH_NAMES[statsMonth - 1]})`,
value: paid.value,
footer: [
paid.detail,
countFooter(stats.paid_month_count, "žádné úhrady"),
]
.filter(Boolean)
.join(" · "),
color: "success",
},
{
label: "Čeká úhrada · celkově",
value: wait.value,
footer: [wait.detail, countFooter(stats.awaiting_count, "vše uhrazeno")]
.filter(Boolean)
.join(" · "),
color: "warning",
},
{
label: "Po splatnosti · celkově",
value: over.value,
footer: [
over.detail,
stats.overdue_count === 0
? "vše v pořádku"
: countFooter(stats.overdue_count, ""),
]
.filter(Boolean)
.join(" · "),
color: "error",
},
{
label: `DPH (${MONTH_NAMES[statsMonth - 1]})`,
value: vat.value,
footer: vat.detail || "z vydaných faktur",
color: "info",
},
];
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
>
{cards.map((c) => (
<StatCard
key={c.label}
label={c.label}
value={c.value}
footer={c.footer}
color={c.color}
/>
))}
</Box>
);
};
// Per-row overdue tint (replaces offers-expired-row).
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
// to avoid the UTC-vs-local off-by-one near midnight Prague.
const rowSx = (inv: Invoice) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
!!inv.due_date &&
normalizeDateStr(inv.due_date) < todayLocalStr());
if (isOverdue) {
return {
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
"&:hover": {
backgroundColor:
"rgba(var(--mui-palette-warning-mainChannel) / 0.18)",
},
};
}
return {};
};
const columns: DataColumn<Invoice>[] = [
{
key: "invoice_number",
header: "Číslo",
width: "15%",
sortKey: "invoice_number",
mono: true,
render: (inv) => (
<Box
component={RouterLink}
to={`/invoices/${inv.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{inv.invoice_number}
</Box>
),
},
{
key: "customer_name",
header: "Zákazník",
width: "22%",
render: (inv) => inv.customer_name || "—",
},
{
key: "status",
header: "Stav",
width: "13%",
sortKey: "status",
render: (inv) =>
inv.status === "paid" ? (
<StatusChip
label={STATUS_LABELS[inv.status]}
color={STATUS_COLORS[inv.status] ?? "default"}
/>
) : (
<StatusChip
label={STATUS_LABELS[inv.status] || inv.status}
color={STATUS_COLORS[inv.status] ?? "default"}
onClick={() => toggleStatus(inv)}
/>
),
},
{
key: "issue_date",
header: "Vystaveno",
width: "12%",
sortKey: "issue_date",
mono: true,
render: (inv) => formatDate(inv.issue_date),
},
{
key: "due_date",
header: "Splatnost",
width: "12%",
sortKey: "due_date",
mono: true,
render: (inv) => (
<Box
component="span"
sx={
inv.status === "overdue"
? { color: "error.main", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</Box>
),
},
{
key: "total",
header: "Celkem",
width: "12%",
align: "right",
mono: true,
bold: true,
render: (inv) => formatCurrency(inv.total, inv.currency),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (inv) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
component={RouterLink}
to={`/invoices/${inv.id}`}
title={inv.status === "paid" ? "Detail" : "Upravit"}
aria-label={inv.status === "paid" ? "Detail" : "Upravit"}
>
{inv.status === "paid" ? ViewIcon : EditIcon}
</IconButton>
{hasPermission("invoices.export") && (
<IconButton
size="small"
onClick={() => handlePdf(inv)}
title="Zobrazit fakturu"
aria-label="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? <CircularProgress size={16} /> : PdfIcon}
</IconButton>
)}
{hasPermission("invoices.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
title="Smazat"
aria-label="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Faktury"
subtitle={subtitle}
actions={
hasPermission("invoices.create") ? (
activeTab === "received" ? (
<Button
startIcon={UploadIcon}
onClick={() => setReceivedUploadOpen(true)}
>
Nahrát faktury
</Button>
) : (
<Button
component={RouterLink}
to="/invoices/new"
startIcon={PlusIcon}
>
Nová faktura
</Button>
)
) : undefined
}
/>
<Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 1,
mb: 2,
}}
>
<IconButton
size="small"
onClick={prevMonth}
aria-label="Předchozí měsíc"
>
{ChevronLeftIcon}
</IconButton>
<Typography
sx={{ minWidth: 140, textAlign: "center", fontWeight: 600 }}
>
{monthLabel}
</Typography>
<IconButton
size="small"
onClick={nextMonth}
disabled={isCurrentMonth}
aria-label="Následující měsíc"
>
{ChevronRightIcon}
</IconButton>
</Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v)}
tabs={[
{ value: "issued", label: "Vydané" },
{ value: "received", label: "Přijaté" },
]}
/>
</Box>
</Box>
{activeTab === "received" ? (
<Suspense fallback={<LoadingState />}>
<ReceivedInvoices
statsMonth={statsMonth}
statsYear={statsYear}
uploadOpen={receivedUploadOpen}
setUploadOpen={setReceivedUploadOpen}
/>
</Suspense>
) : (
<>
{renderKpi()}
<Box sx={{ mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={statusTabs}
/>
</Box>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
fullWidth
/>
</Box>
</FilterBar>
{draft && !search && !statusFilter && (
<Box sx={{ mb: 2 }}>
<Alert severity="info" onClose={discardDraft}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 1.5,
}}
>
<Box>
<Typography component="span" sx={{ fontWeight: 600 }}>
Koncept
</Typography>
{draft.savedAt && (
<Typography
component="span"
sx={{ ml: 1, opacity: 0.8, fontSize: "0.875rem" }}
>
·{" "}
{new Date(draft.savedAt).toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</Typography>
)}
<Typography
variant="body2"
sx={{ color: "text.secondary", mt: 0.25 }}
>
{(draft.form.customer_name as string) || "—"} ·{" "}
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "—"}
{" — "}
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "—"}
</Typography>
</Box>
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/invoices/new"
size="small"
startIcon={EditIcon}
>
Pokračovat v konceptu
</Button>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={discardDraft}
>
Zahodit koncept
</Button>
</Box>
</Box>
</Alert>
</Box>
)}
<Card sx={{ opacity: loading ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<Invoice>
columns={columns}
rows={invoices}
rowKey={(inv) => inv.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<EmptyState
title="Zatím nejsou žádné faktury."
description={
hasPermission("invoices.create")
? "Vytvořte první fakturu tlačítkem výše."
: undefined
}
/>
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
</>
)}
</PageEnter>
);
}