Files
app/src/admin/pages/ReceivedInvoices.tsx
2026-06-07 01:09:43 +02:00

1235 lines
36 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Autocomplete from "@mui/material/Autocomplete";
import MuiTextField from "@mui/material/TextField";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
type ReceivedInvoice,
type CurrencyAmount,
} from "../lib/queries/invoices";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
StatusChip,
StatCard,
EmptyState,
LoadingState,
type DataColumn,
type StatCardColor,
} from "../ui";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
unpaid: "Neuhrazena",
paid: "Uhrazena",
};
const STATUS_COLORS: Record<string, "default" | "success" | "warning"> = {
unpaid: "warning",
paid: "success",
};
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 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 EditInvoicePayload {
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;
}
interface UploadErrors {
[idx: number]: {
[field: string]: string;
};
}
interface ReceivedInvoicesProps {
statsMonth: number;
statsYear: number;
uploadOpen: boolean;
setUploadOpen: (open: boolean) => void;
}
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 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>
);
const RemoveIcon = (
<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>
);
const FileIcon = (
<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>
);
const UploadIcon = (
<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>
);
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 };
}
function emptyMeta(
settings: CompanySettingsData | null | undefined,
): 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 queryClient = useQueryClient();
const { sort, order, handleSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data;
// List query — auto-refetches when filters change
const listQuery = useQuery(
receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
sort,
order,
}),
);
// Stats query — auto-refetches when month/year change
const statsQuery = useQuery(
receivedInvoiceStatsOptions(statsMonth, statsYear),
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = listQuery.data?.data ?? [];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = statsQuery.data ?? null;
// Edit mutation (JSON PUT — useApiMutation compatible)
const editMutation = useApiMutation<
EditInvoicePayload,
{ success: boolean; error?: string; message?: string }
>({
method: "PUT",
url: (payload) => `${API_BASE}/received-invoices/${payload.id}`,
invalidate: ["invoices", "suppliers", "company-settings"],
});
// Delete mutation
const deleteMutation = useApiMutation<
{ id: number },
{ success: boolean; error?: string; message?: string }
>({
method: "DELETE",
url: (payload) => `${API_BASE}/received-invoices/${payload.id}`,
invalidate: ["invoices", "suppliers", "company-settings"],
});
// Toggle paid status mutation
const toggleStatusMutation = useApiMutation<
{ id: number; status: "paid" },
{ success: boolean; error?: string; message?: string }
>({
method: "PUT",
url: ({ id }) => `${API_BASE}/received-invoices/${id}`,
invalidate: ["invoices", "suppliers", "company-settings"],
onSuccess: () => {
alert.success("Faktura označena jako uhrazená");
},
});
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
const [uploadErrors, setUploadErrors] = useState<UploadErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
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);
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({});
queryClient.invalidateQueries({
queryKey: ["invoices"],
});
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} 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: EditInvoicePayload = {
id: editInvoice.id,
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,
};
await editMutation.mutateAsync(payload);
alert.success("Faktura byla aktualizována");
setEditOpen(false);
setEditInvoice(null);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteConfirm.invoice) {
return;
}
setDeleting(true);
try {
await deleteMutation.mutateAsync({ id: deleteConfirm.invoice.id });
alert.success("Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při mazání");
} 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 {
await toggleStatusMutation.mutateAsync({ id: inv.id, status: "paid" });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav");
}
};
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`;
// Live VAT calc for a (amount, rate) pair — preserved verbatim.
const vatOf = (amount: string, rate: string) => {
const a = parseFloat(amount || "0");
const r = parseFloat(rate || defaultVatRate);
return r > 0 ? Math.round((a - a / (1 + r / 100)) * 100) / 100 : 0;
};
const columns: DataColumn<ReceivedInvoice>[] = [
{
key: "supplier_name",
header: "Dodavatel",
width: "20%",
sortKey: "supplier_name",
render: (inv) => inv.supplier_name,
},
{
key: "invoice_number",
header: "Č. faktury",
width: "14%",
sortKey: "invoice_number",
mono: true,
render: (inv) =>
inv.invoice_number ? (
<Box
component="span"
onClick={() => openFile(inv)}
sx={{
color: "primary.main",
cursor: "pointer",
"&:hover": { textDecoration: "underline" },
}}
>
{inv.invoice_number}
</Box>
) : (
"—"
),
},
{
key: "status",
header: "Stav",
width: "12%",
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) => formatDate(inv.due_date),
},
{
key: "amount",
header: "Částka",
width: "14%",
align: "right",
sortKey: "amount",
mono: true,
bold: true,
render: (inv) => formatCurrency(inv.amount, inv.currency),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (inv) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{inv.file_name && (
<IconButton
size="small"
title="Zobrazit soubor"
aria-label="Zobrazit soubor"
onClick={() => openFile(inv)}
>
{ViewIcon}
</IconButton>
)}
{hasPermission("invoices.edit") && (
<IconButton
size="small"
title="Upravit"
aria-label="Upravit"
onClick={() => openEdit(inv)}
>
{EditIcon}
</IconButton>
)}
{hasPermission("invoices.delete") && (
<IconButton
size="small"
color="error"
title="Smazat"
aria-label="Smazat"
onClick={() => setDeleteConfirm({ show: true, invoice: inv })}
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
const renderKpi = () => {
if (statsQuery.isPending && !hasLoadedOnce.current) {
return <LoadingState />;
}
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);
const cards: {
label: string;
value: string;
footer: string;
color: StatCardColor;
}[] = [
{
label: `Celkem (${monthLabel})`,
value: total.value,
footer:
total.detail ||
`${stats.month_count} ${czechPlural(stats.month_count, "faktura", "faktury", "faktur")}`,
color: "success",
},
{
label: `DPH k odpočtu (${monthLabel})`,
value: vat.value,
footer: vat.detail || "z přijatých faktur",
color: "info",
},
{
label: "Neuhrazeno · celkově",
value: unpaid.value,
footer:
unpaid.detail ||
(stats.unpaid_count === 0
? "vše uhrazeno"
: `${stats.unpaid_count} ${czechPlural(stats.unpaid_count, "faktura", "faktury", "faktur")}`),
color: "warning",
},
{
label: `Počet (${monthLabel})`,
value: String(stats.month_count),
footer: stats.month_count === 0 ? "žádné faktury" : "přijatých faktur",
color: "default",
},
];
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>
);
};
return (
<>
{renderKpi()}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card>
<Box sx={{ mb: 2 }}>
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Hledat podle dodavatele nebo čísla faktury..."
fullWidth
/>
</Box>
{showListSkeleton ? (
<LoadingState />
) : (
<DataTable<ReceivedInvoice>
columns={columns}
rows={invoices}
rowKey={(inv) => inv.id}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<EmptyState
title="Žádné přijaté faktury v tomto měsíci."
description={
hasPermission("invoices.create")
? "Nahrajte faktury tlačítkem výše."
: undefined
}
/>
}
/>
)}
</Card>
</motion.div>
{/* Upload Modal */}
<Modal
isOpen={uploadOpen}
onClose={() => setUploadOpen(false)}
onSubmit={handleUploadSave}
title="Nahrát přijaté faktury"
submitText="Uložit vše"
cancelText="Zrušit"
loading={saving}
submitDisabled={uploadFiles.length === 0}
maxWidth="lg"
>
<Box sx={{ mb: 2 }}>
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={UploadIcon}
onClick={() => fileInputRef.current?.click()}
>
Vybrat soubory
</Button>
<Typography
component="span"
variant="caption"
color="text.secondary"
sx={{ ml: 1.5 }}
>
PDF, JPEG, PNG · max 10 MB · max 20 souborů
</Typography>
</Box>
{uploadFiles.length === 0 && (
<EmptyState title="Zatím nebyly vybrány žádné soubory." />
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{uploadFiles.map((file, idx) => (
<Box
key={`${file.name}-${idx}`}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 2,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
mb: 1.5,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
minWidth: 0,
color: "text.secondary",
}}
>
{FileIcon}
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: "text.primary",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{file.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{Math.round(file.size / 1024)} KB
</Typography>
</Box>
<IconButton
size="small"
color="error"
aria-label="Odebrat soubor"
onClick={() => removeUploadFile(idx)}
>
{RemoveIcon}
</IconButton>
</Box>
<Field
label="Dodavatel"
required
error={uploadErrors[idx]?.supplier_name}
>
<Autocomplete
freeSolo
options={supplierNames}
inputValue={uploadMeta[idx]?.supplier_name || ""}
onInputChange={(_, value) =>
updateMeta(idx, "supplier_name", value)
}
renderInput={(params) => (
<MuiTextField
{...params}
size="small"
fullWidth
error={!!uploadErrors[idx]?.supplier_name}
/>
)}
/>
</Field>
<Field label="Č. faktury">
<TextField
value={uploadMeta[idx]?.invoice_number || ""}
onChange={(e) =>
updateMeta(idx, "invoice_number", e.target.value)
}
/>
</Field>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
<Box sx={{ flex: 1 }}>
<Field
label="Částka"
required
error={uploadErrors[idx]?.amount}
>
<TextField
type="number"
slotProps={{ htmlInput: { step: "0.01", min: "0" } }}
value={uploadMeta[idx]?.amount || ""}
onChange={(e) =>
updateMeta(idx, "amount", e.target.value)
}
error={!!uploadErrors[idx]?.amount}
/>
</Field>
</Box>
<Box sx={{ width: 110 }}>
<Field label="Měna">
<Select
value={uploadMeta[idx]?.currency || defaultCurrency}
onChange={(value) => updateMeta(idx, "currency", value)}
options={currencyOptions.map((c) => ({
value: c,
label: c,
}))}
/>
</Field>
</Box>
<Box sx={{ width: 110 }}>
<Field label="DPH %">
<Select
value={uploadMeta[idx]?.vat_rate || defaultVatRate}
onChange={(value) => updateMeta(idx, "vat_rate", value)}
options={vatRateOptions.map((r) => ({
value: String(r),
label: `${r}%`,
}))}
/>
</Field>
</Box>
</Box>
{uploadMeta[idx]?.amount && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: -1, mb: 1 }}
>
DPH:{" "}
{formatCurrency(
vatOf(uploadMeta[idx].amount, uploadMeta[idx].vat_rate),
uploadMeta[idx].currency || defaultCurrency,
)}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Field label="Datum vystavení">
<DateField
value={uploadMeta[idx]?.issue_date || ""}
onChange={(val) => updateMeta(idx, "issue_date", val)}
/>
</Field>
</Box>
<Box sx={{ flex: 1 }}>
<Field label="Datum splatnosti">
<DateField
value={uploadMeta[idx]?.due_date || ""}
onChange={(val) => updateMeta(idx, "due_date", val)}
/>
</Field>
</Box>
</Box>
<Field label="Poznámka">
<TextField
value={uploadMeta[idx]?.notes || ""}
onChange={(e) => updateMeta(idx, "notes", e.target.value)}
/>
</Field>
</Box>
))}
</Box>
{saving && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 1 }}
>
Nahrávání...
</Typography>
)}
{uploadFiles.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 1 }}
>
Vyberte alespoň jeden soubor pro uložení.
</Typography>
)}
</Modal>
{/* Edit Modal — read-only variant when the invoice was already paid */}
<Modal
isOpen={editOpen}
onClose={() => setEditOpen(false)}
onSubmit={
editInvoice && editInvoice._originalStatus !== "paid"
? handleEditSave
: () => setEditOpen(false)
}
title={
editInvoice && editInvoice._originalStatus === "paid"
? "Detail přijaté faktury"
: "Upravit přijatou fakturu"
}
submitText={
editInvoice && editInvoice._originalStatus === "paid"
? "Zavřít"
: "Uložit"
}
cancelText={
editInvoice && editInvoice._originalStatus === "paid"
? "Zavřít"
: "Zrušit"
}
loading={saving}
>
{editInvoice &&
(() => {
const ro = editInvoice._originalStatus === "paid";
return (
<>
<Field label="Dodavatel" required>
<Autocomplete
freeSolo
disabled={ro}
options={supplierNames}
inputValue={editInvoice.supplier_name}
onInputChange={(_, value) =>
setEditInvoice((prev) =>
prev ? { ...prev, supplier_name: value } : null,
)
}
renderInput={(params) => (
<MuiTextField {...params} size="small" fullWidth />
)}
/>
</Field>
<Field label="Č. faktury">
<TextField
value={editInvoice.invoice_number || ""}
onChange={(e) =>
setEditInvoice((prev) =>
prev
? { ...prev, invoice_number: e.target.value }
: null,
)
}
slotProps={{ input: { readOnly: ro } }}
/>
</Field>
<Box
sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}
>
<Box sx={{ flex: 1 }}>
<Field label="Částka" required>
<TextField
type="number"
slotProps={{
htmlInput: { step: "0.01", min: "0" },
input: { readOnly: ro },
}}
value={editInvoice.amount}
onChange={(e) =>
setEditInvoice((prev) =>
prev ? { ...prev, amount: e.target.value } : null,
)
}
/>
</Field>
</Box>
<Box sx={{ flex: 1 }}>
<Field label="Měna">
<Select
value={editInvoice.currency}
disabled={ro}
onChange={(value) =>
setEditInvoice((prev) =>
prev ? { ...prev, currency: value } : null,
)
}
options={currencyOptions.map((c) => ({
value: c,
label: c,
}))}
/>
</Field>
</Box>
<Box sx={{ flex: 1 }}>
<Field label="DPH %">
<Select
value={editInvoice.vat_rate}
disabled={ro}
onChange={(value) =>
setEditInvoice((prev) =>
prev ? { ...prev, vat_rate: value } : null,
)
}
options={vatRateOptions.map((r) => ({
value: String(r),
label: `${r}%`,
}))}
/>
</Field>
</Box>
</Box>
{editInvoice.amount && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 1.5 }}
>
DPH:{" "}
{formatCurrency(
vatOf(editInvoice.amount, editInvoice.vat_rate),
editInvoice.currency || defaultCurrency,
)}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Field label="Datum vystavení">
<DateField
value={editInvoice.issue_date || ""}
disabled={ro}
onChange={(val) =>
setEditInvoice((prev) =>
prev ? { ...prev, issue_date: val } : null,
)
}
/>
</Field>
</Box>
<Box sx={{ flex: 1 }}>
<Field label="Datum splatnosti">
<DateField
value={editInvoice.due_date || ""}
disabled={ro}
onChange={(val) =>
setEditInvoice((prev) =>
prev ? { ...prev, due_date: val } : null,
)
}
/>
</Field>
</Box>
</Box>
<Field label="Stav">
<Select
value={editInvoice.status}
disabled={ro}
onChange={(value) =>
setEditInvoice((prev) =>
prev ? { ...prev, status: value } : null,
)
}
options={[
{ value: "unpaid", label: "Neuhrazena" },
{ value: "paid", label: "Uhrazena" },
]}
/>
</Field>
<Field label="Poznámka">
<TextField
multiline
rows={3}
value={editInvoice.notes || ""}
onChange={(e) =>
setEditInvoice((prev) =>
prev ? { ...prev, notes: e.target.value } : null,
)
}
slotProps={{ input: { readOnly: ro } }}
/>
</Field>
</>
);
})()}
</Modal>
<ConfirmDialog
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"
confirmVariant="danger"
loading={deleting}
/>
</>
);
}