- 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>
1125 lines
41 KiB
TypeScript
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>
|
|
);
|
|
}
|