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>
876 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|