1235 lines
36 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|