- 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>
1469 lines
52 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|