Files
app/src/admin/pages/ReceivedInvoices.tsx
BOHA d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:40:38 +02:00

1469 lines
52 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
unpaid: "Neuhrazena",
paid: "Uhrazena",
};
const STATUS_CLASSES: Record<string, string> = {
unpaid: "admin-badge-invoice-overdue",
paid: "admin-badge-invoice-paid",
};
const DEFAULT_CURRENCIES = ["CZK", "EUR", "USD", "GBP"];
const DEFAULT_VAT_RATES = [0, 10, 12, 15, 21];
const MONTH_NAMES = [
"leden",
"únor",
"březen",
"duben",
"květen",
"červen",
"červenec",
"srpen",
"září",
"říjen",
"listopad",
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
interface ReceivedInvoice {
id: number;
supplier_name: string;
invoice_number: string;
amount: number;
currency: string;
vat_rate: number;
issue_date: string;
due_date: string;
notes: string;
status: string;
file_name?: string;
created_at: string;
}
interface ReceivedStats {
total_month: CurrencyAmount[];
total_month_czk: number | null;
vat_month: CurrencyAmount[];
vat_month_czk: number | null;
unpaid: CurrencyAmount[];
unpaid_czk: number | null;
unpaid_count: number;
month_count: number;
}
interface UploadMeta {
supplier_name: string;
invoice_number: string;
amount: string;
currency: string;
vat_rate: string;
issue_date: string;
due_date: string;
notes: string;
}
interface EditInvoice extends Omit<ReceivedInvoice, "amount" | "vat_rate"> {
amount: string;
vat_rate: string;
_originalStatus: string;
}
interface UploadErrors {
[idx: number]: {
[field: string]: string;
};
}
interface ReceivedInvoicesProps {
statsMonth: number;
statsYear: number;
uploadOpen: boolean;
setUploadOpen: (open: boolean) => void;
}
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 };
}
interface CompanySettings {
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
amount: "",
currency: settings?.default_currency || "CZK",
vat_rate: String(settings?.default_vat_rate ?? 21),
issue_date: "",
due_date: "",
notes: "",
};
}
export default function ReceivedInvoices({
statsMonth,
statsYear,
uploadOpen,
setUploadOpen,
}: ReceivedInvoicesProps) {
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<ReceivedStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [slideKey, setSlideKey] = useState(0);
const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear);
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [supplierNames, setSupplierNames] = useState<string[]>([]);
const [companySettings, setCompanySettings] =
useState<CompanySettings | null>(null);
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
const [uploadErrors, setUploadErrors] = useState<UploadErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null);
useModalLock(uploadOpen || editOpen);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current;
const curr = statsYear * 12 + statsMonth;
if (curr > prev) {
slideDirection.current = 1;
}
if (curr < prev) {
slideDirection.current = -1;
}
prevMonth.current = statsMonth;
prevYear.current = statsYear;
}, [statsMonth, statsYear]);
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
});
if (search) {
params.set("search", search);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`);
const data = await res.json();
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : []);
}
} catch {
/* ignore */
} finally {
setLoading(false);
hasLoadedOnce.current = true;
}
}, [statsMonth, statsYear, search, sort, order]);
useEffect(() => {
fetchList();
}, [fetchList]);
useEffect(() => {
apiFetch(`${API_BASE}/received-invoices/suppliers`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSupplierNames(d.data || []);
})
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions =
companySettings?.available_vat_rates || DEFAULT_VAT_RATES;
const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
}
} catch {
/* ignore */
}
}, [statsMonth, statsYear]);
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true);
const load = async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-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);
}
};
load();
}, [statsMonth, statsYear]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (selected.length === 0) {
return;
}
if (uploadFiles.length + selected.length > 20) {
alert.error("Maximálně 20 souborů najednou");
return;
}
const valid = selected.filter((f) => {
if (f.size > 10 * 1024 * 1024) {
alert.error(`Soubor "${f.name}" je větší než 10 MB`);
return false;
}
const allowed = ["application/pdf", "image/jpeg", "image/png"];
if (!allowed.includes(f.type)) {
alert.error(`Soubor "${f.name}": nepodporovaný formát`);
return false;
}
return true;
});
setUploadFiles((prev) => [...prev, ...valid]);
setUploadMeta((prev) => [
...prev,
...valid.map(() => emptyMeta(companySettings)),
]);
e.target.value = "";
};
const removeUploadFile = (idx: number) => {
setUploadFiles((prev) => prev.filter((_, i) => i !== idx));
setUploadMeta((prev) => prev.filter((_, i) => i !== idx));
const newErrors = { ...uploadErrors };
delete newErrors[idx];
setUploadErrors(newErrors);
};
const updateMeta = (idx: number, field: keyof UploadMeta, value: string) => {
setUploadMeta((prev) =>
prev.map((m, i) => (i === idx ? { ...m, [field]: value } : m)),
);
if (uploadErrors[idx]) {
const newErrors = { ...uploadErrors };
if (newErrors[idx]?.[field]) {
delete newErrors[idx][field];
if (Object.keys(newErrors[idx]).length === 0) {
delete newErrors[idx];
}
}
setUploadErrors(newErrors);
}
};
const validateUpload = (): boolean => {
const errors: UploadErrors = {};
uploadMeta.forEach((m, i) => {
const e: Record<string, string> = {};
if (!m.supplier_name.trim()) {
e.supplier_name = "Povinné pole";
}
if (!m.amount || parseFloat(m.amount) <= 0) {
e.amount = "Částka musí být větší než 0";
}
if (Object.keys(e).length > 0) {
errors[i] = e;
}
});
setUploadErrors(errors);
return Object.keys(errors).length === 0;
};
const handleUploadSave = async () => {
if (uploadFiles.length === 0) {
alert.error("Vyberte alespoň jeden soubor");
return;
}
if (!validateUpload()) {
return;
}
setSaving(true);
try {
const formData = new FormData();
uploadFiles.forEach((f) => formData.append("files[]", f));
formData.append("invoices", JSON.stringify(uploadMeta));
const res = await apiFetch(`${API_BASE}/received-invoices`, {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.success) {
alert.success(data.message || "Faktury byly nahrány");
setUploadOpen(false);
setUploadFiles([]);
setUploadMeta([]);
setUploadErrors({});
fetchList();
refreshStats();
} else {
alert.error(data.error || "Chyba při nahrávání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const toDateInput = (d: string | null | undefined): string => {
if (!d) return "";
const date = new Date(d);
if (isNaN(date.getTime())) return "";
return date.toISOString().split("T")[0];
};
const openEdit = (inv: ReceivedInvoice) => {
setEditInvoice({
...inv,
amount: String(inv.amount),
vat_rate: String(inv.vat_rate),
issue_date: toDateInput(inv.issue_date),
due_date: toDateInput(inv.due_date),
_originalStatus: inv.status,
});
setEditOpen(true);
};
const handleEditSave = async () => {
if (!editInvoice) {
return;
}
if (!editInvoice.supplier_name?.trim()) {
alert.error("Dodavatel je povinný");
return;
}
if (!editInvoice.amount || parseFloat(editInvoice.amount) <= 0) {
alert.error("Částka musí být větší než 0");
return;
}
setSaving(true);
try {
const payload = {
supplier_name: editInvoice.supplier_name,
invoice_number: editInvoice.invoice_number || "",
amount: parseFloat(editInvoice.amount),
currency: editInvoice.currency,
vat_rate: parseFloat(editInvoice.vat_rate),
issue_date: editInvoice.issue_date || "",
due_date: editInvoice.due_date || "",
notes: editInvoice.notes || "",
status: editInvoice.status,
};
const res = await apiFetch(
`${API_BASE}/received-invoices/${editInvoice.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const data = await res.json();
if (data.success) {
alert.success(data.message || "Faktura byla aktualizována");
setEditOpen(false);
setEditInvoice(null);
fetchList();
refreshStats();
} else {
alert.error(data.error || "Chyba při ukládání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteConfirm.invoice) {
return;
}
setDeleting(true);
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/${deleteConfirm.invoice.id}`,
{
method: "DELETE",
},
);
const data = await res.json();
if (data.success) {
alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
fetchList();
refreshStats();
} else {
alert.error(data.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
const openFile = async (inv: ReceivedInvoice) => {
const newWindow = window.open("", "_blank");
try {
const response = await apiFetch(
`${API_BASE}/received-invoices/${inv.id}/file`,
);
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se načíst soubor");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
const timeoutId = setTimeout(() => URL.revokeObjectURL(url), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
}
};
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === "paid") return;
try {
const res = await apiFetch(`${API_BASE}/received-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 uhrazená");
fetchList();
refreshStats();
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
}
};
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`;
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
return (
<div className="admin-kpi-grid admin-kpi-4 mb-6">
{[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>
);
}
if (!stats) {
return null;
}
const total = formatCzkWithDetail(stats.total_month, stats.total_month_czk);
const vat = formatCzkWithDetail(stats.vat_month, stats.vat_month_czk);
const unpaid = formatCzkWithDetail(stats.unpaid, stats.unpaid_czk);
return (
<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 }}
>
<div className="admin-stat-card success">
<div className="admin-stat-label">Celkem ({monthLabel})</div>
<div className="admin-stat-value admin-mono">{total.value}</div>
<div className="admin-stat-footer">
{total.detail ||
`${stats.month_count} ${czechPlural(stats.month_count, "faktura", "faktury", "faktur")}`}
</div>
</div>
<div className="admin-stat-card info">
<div className="admin-stat-label">
DPH k odpočtu ({monthLabel})
</div>
<div className="admin-stat-value admin-mono">{vat.value}</div>
<div className="admin-stat-footer">
{vat.detail || "z přijatých faktur"}
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-label">
Neuhrazeno{" "}
<span style={{ fontWeight: 400, opacity: 0.7 }}>· celkově</span>
</div>
<div className="admin-stat-value admin-mono">{unpaid.value}</div>
<div className="admin-stat-footer">
{unpaid.detail ||
(stats.unpaid_count === 0
? "vše uhrazeno"
: `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, "faktura", "faktury", "faktur")}`)}
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-label">Počet ({monthLabel})</div>
<div className="admin-stat-value admin-mono">
{stats.month_count}
</div>
<div className="admin-stat-footer">
{stats.month_count === 0 ? "žádné faktury" : `přijatých faktur`}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
);
};
return (
<>
{renderKpi()}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
></motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat podle dodavatele nebo čísla faktury..."
/>
</div>
{loading && (
<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 w-1/4" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && invoices.length === 0 && (
<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" />
</svg>
</div>
<p>Žádné přijaté faktury v tomto měsíci.</p>
{hasPermission("invoices.create") && (
<p
className="text-md"
style={{
color: "var(--text-tertiary)",
}}
>
Nahrajte faktury tlačítkem výše.
</p>
)}
</div>
)}
{!loading && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("supplier_name")}
>
Dodavatel{" "}
<SortIcon
column="supplier_name"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("invoice_number")}
>
Č. faktury{" "}
<SortIcon
column="invoice_number"
sort={activeSort}
order={order}
/>
</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"
style={{ cursor: "pointer" }}
onClick={() => handleSort("amount")}
>
Částka{" "}
<SortIcon
column="amount"
sort={activeSort}
order={order}
/>
</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id}>
<td>{inv.supplier_name}</td>
<td className="admin-mono">
{inv.invoice_number ? (
<span
className="link-accent"
style={{ cursor: "pointer" }}
onClick={() => openFile(inv)}
>
{inv.invoice_number}
</span>
) : (
"—"
)}
</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">{formatDate(inv.due_date)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(inv.amount, inv.currency)}
</td>
<td>
<div className="admin-table-actions">
{inv.file_name && (
<button
className="admin-btn-icon"
title="Zobrazit soubor"
onClick={() => openFile(inv)}
>
<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>
</button>
)}
{hasPermission("invoices.edit") && (
<button
className="admin-btn-icon"
title="Upravit"
onClick={() => openEdit(inv)}
>
<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>
</button>
)}
{hasPermission("invoices.delete") && (
<button
className="admin-btn-icon danger"
title="Smazat"
onClick={() =>
setDeleteConfirm({ show: true, invoice: inv })
}
>
<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>
)}
</div>
</motion.div>
{/* Upload Modal */}
<AnimatePresence>
{uploadOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => !saving && setUploadOpen(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Nahrát přijaté faktury</h2>
</div>
<div className="admin-modal-body">
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
>
<svg
width="16"
height="16"
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>
Vybrat soubory
</button>
<span
className="text-sm"
style={{
marginLeft: "0.75rem",
color: "var(--text-tertiary)",
}}
>
PDF, JPEG, PNG · max 10 MB · max 20 souborů
</span>
</div>
{uploadFiles.length === 0 && (
<div
className="admin-empty-state"
style={{ padding: "2rem 0" }}
>
<p style={{ color: "var(--text-tertiary)" }}>
Zatím nebyly vybrány žádné soubory.
</p>
</div>
)}
<div className="received-upload-list">
{uploadFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="received-upload-card"
>
<div className="received-upload-card-header">
<div className="received-upload-file-info">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="received-upload-file-name">
{file.name}
</span>
<span className="received-upload-file-size">
{Math.round(file.size / 1024)} KB
</span>
</div>
<button
className="admin-btn-icon danger"
style={{ width: "24px", height: "24px" }}
onClick={() => removeUploadFile(idx)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="received-upload-card-fields">
<FormField
label="Dodavatel"
error={uploadErrors[idx]?.supplier_name}
required
>
<input
type="text"
list="supplier-suggestions"
className={`admin-form-input${uploadErrors[idx]?.supplier_name ? " has-error" : ""}`}
value={uploadMeta[idx]?.supplier_name || ""}
onChange={(e) =>
updateMeta(idx, "supplier_name", e.target.value)
}
autoComplete="off"
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.invoice_number || ""}
onChange={(e) =>
updateMeta(idx, "invoice_number", e.target.value)
}
/>
</FormField>
<div className="received-upload-row">
<FormField
label="Částka"
error={uploadErrors[idx]?.amount}
required
style={{ flex: 1 }}
>
<input
type="number"
step="0.01"
min="0"
className={`admin-form-input${uploadErrors[idx]?.amount ? " has-error" : ""}`}
value={uploadMeta[idx]?.amount || ""}
onChange={(e) =>
updateMeta(idx, "amount", e.target.value)
}
/>
</FormField>
<FormField label="Měna" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={
uploadMeta[idx]?.currency || defaultCurrency
}
onChange={(e) =>
updateMeta(idx, "currency", e.target.value)
}
>
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="DPH %" style={{ width: "90px" }}>
<select
className="admin-form-select"
value={
uploadMeta[idx]?.vat_rate || defaultVatRate
}
onChange={(e) =>
updateMeta(idx, "vat_rate", e.target.value)
}
>
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
))}
</select>
</FormField>
</div>
{uploadMeta[idx]?.amount && (
<div
className="text-xs"
style={{
color: "var(--text-tertiary)",
marginTop: "-0.25rem",
marginBottom: "0.5rem",
}}
>
DPH:{" "}
{formatCurrency(
(() => {
const a = parseFloat(
uploadMeta[idx].amount || "0",
);
const r = parseFloat(
uploadMeta[idx].vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
uploadMeta[idx].currency || defaultCurrency,
)}
</div>
)}
<div className="received-upload-row">
<FormField
label="Datum vystavení"
style={{ flex: 1 }}
>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.issue_date || ""}
onChange={(val: string) =>
updateMeta(idx, "issue_date", val)
}
/>
</FormField>
<FormField
label="Datum splatnosti"
style={{ flex: 1 }}
>
<AdminDatePicker
mode="date"
value={uploadMeta[idx]?.due_date || ""}
onChange={(val: string) =>
updateMeta(idx, "due_date", val)
}
/>
</FormField>
</div>
<FormField label="Poznámka">
<input
type="text"
className="admin-form-input"
value={uploadMeta[idx]?.notes || ""}
onChange={(e) =>
updateMeta(idx, "notes", e.target.value)
}
/>
</FormField>
</div>
</div>
))}
</div>
</div>
<div className="admin-modal-footer">
<button
className="admin-btn admin-btn-secondary"
onClick={() => !saving && setUploadOpen(false)}
disabled={saving}
>
Zrušit
</button>
<button
className="admin-btn admin-btn-primary"
onClick={handleUploadSave}
disabled={saving || uploadFiles.length === 0}
>
{saving ? "Nahrávání..." : "Uložit vše"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Edit Modal */}
<AnimatePresence>
{editOpen && editInvoice && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => !saving && setEditOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{(() => {
const ro = editInvoice._originalStatus === "paid";
return (
<>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{ro
? "Detail přijaté faktury"
: "Upravit přijatou fakturu"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Dodavatel" required>
<input
type="text"
list="supplier-suggestions"
className="admin-form-input"
value={editInvoice.supplier_name}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, supplier_name: e.target.value }
: null,
)
}
readOnly={ro}
autoComplete="off"
/>
</FormField>
<FormField label="Č. faktury">
<input
type="text"
className="admin-form-input"
value={editInvoice.invoice_number || ""}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, invoice_number: e.target.value }
: null,
)
}
readOnly={ro}
/>
</FormField>
<div className="admin-form-row admin-form-row-3">
<FormField label="Částka" required>
<input
type="number"
step="0.01"
min="0"
className="admin-form-input"
value={editInvoice.amount}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, amount: e.target.value }
: null,
)
}
readOnly={ro}
/>
</FormField>
<FormField label="Měna">
<select
className="admin-form-select"
value={editInvoice.currency}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, currency: e.target.value }
: null,
)
}
disabled={ro}
>
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="DPH %">
<select
className="admin-form-select"
value={editInvoice.vat_rate}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, vat_rate: e.target.value }
: null,
)
}
disabled={ro}
>
{vatRateOptions.map((r) => (
<option key={r} value={String(r)}>
{r}%
</option>
))}
</select>
</FormField>
</div>
{editInvoice.amount && (
<div
className="text-xs"
style={{
color: "var(--text-tertiary)",
marginBottom: "0.75rem",
}}
>
DPH:{" "}
{formatCurrency(
(() => {
const a = parseFloat(editInvoice.amount || "0");
const r = parseFloat(
editInvoice.vat_rate || defaultVatRate,
);
return r > 0
? Math.round((a - a / (1 + r / 100)) * 100) /
100
: 0;
})(),
editInvoice.currency || defaultCurrency,
)}
</div>
)}
<div className="admin-form-row">
<FormField label="Datum vystavení">
<AdminDatePicker
mode="date"
value={editInvoice.issue_date || ""}
onChange={(val: string) =>
setEditInvoice((prev) =>
prev ? { ...prev, issue_date: val } : null,
)
}
disabled={ro}
/>
</FormField>
<FormField label="Datum splatnosti">
<AdminDatePicker
mode="date"
value={editInvoice.due_date || ""}
onChange={(val: string) =>
setEditInvoice((prev) =>
prev ? { ...prev, due_date: val } : null,
)
}
disabled={ro}
/>
</FormField>
</div>
<FormField label="Stav">
<select
className="admin-form-select"
value={editInvoice.status}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, status: e.target.value }
: null,
)
}
disabled={ro}
>
<option value="unpaid">Neuhrazena</option>
<option value="paid">Uhrazena</option>
</select>
</FormField>
<FormField label="Poznámka">
<textarea
className="admin-form-input"
rows={3}
value={editInvoice.notes || ""}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, notes: e.target.value }
: null,
)
}
readOnly={ro}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
{ro ? (
<button
className="admin-btn admin-btn-secondary"
onClick={() => setEditOpen(false)}
>
Zavřít
</button>
) : (
<>
<button
className="admin-btn admin-btn-secondary"
onClick={() => !saving && setEditOpen(false)}
disabled={saving}
>
Zrušit
</button>
<button
className="admin-btn admin-btn-primary"
onClick={handleEditSave}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit"}
</button>
</>
)}
</div>
</>
);
})()}
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat přijatou fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.supplier_name || ""}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<datalist id="supplier-suggestions">
{supplierNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</>
);
}