Files
app/src/admin/pages/Invoices.tsx
BOHA 528e55991b security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 00:58:35 +02:00

1125 lines
41 KiB
TypeScript

import {
useState,
useEffect,
useCallback,
useRef,
lazy,
Suspense,
} from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import Pagination from "../components/Pagination";
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",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
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_CLASSES: Record<string, string> = {
issued: "admin-badge-invoice-issued",
paid: "admin-badge-invoice-paid",
overdue: "admin-badge-invoice-overdue",
};
const STATUS_FILTERS = [
{ value: "", label: "Vše" },
{ value: "issued", label: "Vystavené" },
{ value: "paid", label: "Zaplacené" },
{ value: "overdue", label: "Po splatnosti" },
];
interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
interface DraftData {
form: Record<string, unknown>;
items: Record<string, unknown>[];
savedAt?: string;
}
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, activeSort } =
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 [stats, setStats] = useState<InvoiceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0);
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 fetchStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await apiFetch(
`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
}, [statsMonth, statsYear]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const prevMonth = () => {
slideDirection.current = -1;
if (statsMonth === 1) {
setStatsMonth(12);
setStatsYear((y) => y - 1);
} else {
setStatsMonth((m) => m - 1);
}
};
const nextMonth = () => {
if (isCurrentMonth) return;
slideDirection.current = 1;
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 {
items: invoices,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData<Invoice>("invoices", {
search,
sort,
order,
page,
extraParams: {
month: String(statsMonth),
year: String(statsYear),
...(statusFilter ? { status: statusFilter } : {}),
},
errorMsg: "Nepodařilo se načíst faktury",
});
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");
fetchData();
fetchStats();
} 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á");
fetchData();
fetchStats();
} 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 (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line w-1/4" />
<div
className="admin-skeleton-line"
style={{ width: "70px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100px" }}
/>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? invoices.length}{" "}
{czechPlural(
pagination?.total ?? invoices.length,
"faktura",
"faktury",
"faktur",
)}
</p>
</div>
{hasPermission("invoices.create") && (
<div className="admin-page-actions">
{activeTab === "received" ? (
<button
className="admin-btn admin-btn-primary"
onClick={() => setReceivedUploadOpen(true)}
>
<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>
Nahrát faktury
</button>
) : (
<Link to="/invoices/new" className="admin-btn admin-btn-primary">
<svg
width="18"
height="18"
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>
Nová faktura
</Link>
)}
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="invoice-month-nav">
<button
className="invoice-month-btn"
onClick={prevMonth}
aria-label="Předchozí měsíc"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span>{monthLabel}</span>
<button
className="invoice-month-btn"
onClick={nextMonth}
disabled={isCurrentMonth}
aria-label="Následující měsíc"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
<div className="admin-tabs mb-4" style={{ justifyContent: "center" }}>
<button
className={`admin-tab ${activeTab === "issued" ? "active" : ""}`}
onClick={() => setActiveTab("issued")}
>
Vydané
</button>
<button
className={`admin-tab ${activeTab === "received" ? "active" : ""}`}
onClick={() => setActiveTab("received")}
>
Přijaté
</button>
</div>
</motion.div>
{activeTab === "received" ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<Suspense
fallback={
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
}
>
<ReceivedInvoices
statsMonth={statsMonth}
statsYear={statsYear}
uploadOpen={receivedUploadOpen}
setUploadOpen={setReceivedUploadOpen}
/>
</Suspense>
</motion.div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
{!hasLoadedOnce.current && statsLoading ? (
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>
<AnimatePresence
mode="popLayout"
initial={false}
custom={slideDirection.current}
>
<motion.div
key={slideKey}
className="admin-kpi-grid admin-kpi-4"
custom={slideDirection.current}
variants={{
enter: (dir: number) => ({
x: `${(dir || 0) * 105}%`,
opacity: 0,
}),
center: { x: "0%", opacity: 1 },
exit: (dir: number) => ({
x: `${(dir || 0) * -105}%`,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
>
{(() => {
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;
return (
<>
<div className="admin-stat-card success">
<div className="admin-stat-label">
Uhrazeno ({MONTH_NAMES[statsMonth - 1]})
</div>
<div className="admin-stat-value admin-mono">
{paid.value}
</div>
<div className="admin-stat-footer">
{[
paid.detail,
countFooter(
stats.paid_month_count,
"žádné úhrady",
),
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">
Čeká úhrada{" "}
<span style={{ fontWeight: 400, opacity: 0.7 }}>
· celkově
</span>
</div>
<div className="admin-stat-value admin-mono">
{wait.value}
</div>
<div className="admin-stat-footer">
{[
wait.detail,
countFooter(
stats.awaiting_count,
"vše uhrazeno",
),
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<div className="admin-stat-card danger">
<div className="admin-stat-label">
Po splatnosti{" "}
<span style={{ fontWeight: 400, opacity: 0.7 }}>
· celkově
</span>
</div>
<div className="admin-stat-value admin-mono">
{over.value}
</div>
<div className="admin-stat-footer">
{[
over.detail,
stats.overdue_count === 0
? "vše v pořádku"
: countFooter(stats.overdue_count, ""),
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">
DPH ({MONTH_NAMES[statsMonth - 1]})
</div>
<div className="admin-stat-value admin-mono">
{vat.value}
</div>
<div className="admin-stat-footer">
{vat.detail || "z vydaných faktur"}
</div>
</div>
</>
);
})()}
</motion.div>
</AnimatePresence>
</div>
)
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-tabs mb-6">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
className={`admin-tab ${statusFilter === f.value ? "active" : ""}`}
onClick={() => {
setStatusFilter(f.value);
setPage(1);
}}
>
{f.label}
</button>
))}
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
/>
</div>
{invoices.length === 0 && !(draft && !statusFilter) ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<p>Zatím nejsou žádné faktury.</p>
{hasPermission("invoices.create") && (
<p
className="text-tertiary"
style={{ fontSize: "0.875rem" }}
>
Vytvořte první fakturu tlačítkem výše.
</p>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
>
Číslo{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("issue_date")}
>
Vystaveno{" "}
<SortIcon
column="issue_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("due_date")}
>
Splatnost{" "}
<SortIcon
column="due_date"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{draft && !search && !statusFilter && (
<tr className="offers-draft-row">
<td>
<span className="offers-draft-row-label">
Koncept
{draft.savedAt && (
<span style={{ fontWeight: 400, opacity: 0.8 }}>
{" · "}
{new Date(draft.savedAt).toLocaleTimeString(
"cs-CZ",
{ hour: "2-digit", minute: "2-digit" },
)}
</span>
)}
</span>
</td>
<td>
{(draft.form.customer_name as string) || "\u2014"}
</td>
<td>{"\u2014"}</td>
<td className="admin-mono">
{draft.form.issue_date
? formatDate(draft.form.issue_date as string)
: "\u2014"}
</td>
<td className="admin-mono">
{draft.form.due_date
? formatDate(draft.form.due_date as string)
: "\u2014"}
</td>
<td />
<td>
<div className="admin-table-actions">
<Link
to="/invoices/new"
className="admin-btn-icon"
title="Pokračovat v konceptu"
aria-label="Pokračovat v konceptu"
>
<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>
</Link>
<button
onClick={discardDraft}
className="admin-btn-icon danger"
title="Zahodit koncept"
>
<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>
</button>
</div>
</td>
</tr>
)}
{invoices.map((inv) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) <
new Date(new Date().toDateString()));
return (
<tr
key={inv.id}
className={isOverdue ? "offers-expired-row" : ""}
>
<td className="admin-mono">
<Link
to={`/invoices/${inv.id}`}
className="link-accent"
>
{inv.invoice_number}
</Link>
</td>
<td>{inv.customer_name || "\u2014"}</td>
<td>
{inv.status === "paid" ? (
<span
className={`admin-badge ${STATUS_CLASSES[inv.status]}`}
>
{STATUS_LABELS[inv.status]}
</span>
) : (
<button
onClick={() => toggleStatus(inv)}
className={`admin-badge ${STATUS_CLASSES[inv.status] || ""}`}
style={{ cursor: "pointer" }}
>
{STATUS_LABELS[inv.status] || inv.status}
</button>
)}
</td>
<td className="admin-mono">
{formatDate(inv.issue_date)}
</td>
<td
className="admin-mono"
style={
inv.status === "overdue"
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{formatDate(inv.due_date)}
</td>
<td
className="admin-mono"
style={{ textAlign: "right", fontWeight: 500 }}
>
{formatCurrency(inv.total, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${inv.id}`}
className="admin-btn-icon"
title={
inv.status === "paid" ? "Detail" : "Upravit"
}
aria-label={
inv.status === "paid" ? "Detail" : "Upravit"
}
>
{inv.status === "paid" ? (
<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>
) : (
<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>
)}
</Link>
{hasPermission("invoices.export") && (
<button
onClick={() => handlePdf(inv)}
className="admin-btn-icon"
title="Zobrazit fakturu"
disabled={pdfLoading === inv.id}
>
{pdfLoading === inv.id ? (
<div
className="admin-spinner"
style={{
width: 18,
height: 18,
borderWidth: 2,
}}
/>
) : (
<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>
)}
</button>
)}
{hasPermission("invoices.delete") && (
<button
onClick={() =>
setDeleteConfirm({
show: true,
invoice: inv,
})
}
className="admin-btn-icon danger"
title="Smazat"
>
<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>
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
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"
type="danger"
loading={deleting}
/>
</>
)}
</div>
);
}