v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix

Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -1,20 +1,20 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import Pagination from "../components/Pagination";
import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin/warehouse/suppliers";
@@ -34,13 +34,17 @@ const PER_PAGE = 20;
export default function WarehouseSuppliers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data: suppliersData, isPending } = useQuery(
const {
items: suppliers,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<WarehouseSupplier>(
warehouseSupplierListOptions({
search: debouncedSearch || undefined,
page,
@@ -48,9 +52,6 @@ export default function WarehouseSuppliers() {
}),
);
const suppliers = suppliersData?.data ?? [];
const pagination = suppliersData?.pagination ?? null;
const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null);
@@ -70,8 +71,46 @@ export default function WarehouseSuppliers() {
show: boolean;
supplier: WarehouseSupplier | null;
}>({ show: false, supplier: null });
const [saving, setSaving] = useState(false);
const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState<
number | null
>(null);
useModalLock(showModal);
const submitMutation = useApiMutation<
SupplierForm,
{ id?: number; message?: string }
>({
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
},
});
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
},
});
const toggleActiveMutation = useApiMutation<
{ is_active: boolean },
{ message?: string }
>({
url: () => {
if (!toggleActiveSupplierId) return API_BASE;
return `${API_BASE}/${toggleActiveSupplierId}`;
},
method: () => "PUT",
invalidate: ["warehouse"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;
@@ -113,32 +152,13 @@ export default function WarehouseSuppliers() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = editingSupplier
? `${API_BASE}/${editingSupplier.id}`
: API_BASE;
const method = editingSupplier ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -146,55 +166,27 @@ export default function WarehouseSuppliers() {
if (!deactivateConfirm.supplier) return;
try {
const response = await apiFetch(
`${API_BASE}/${deactivateConfirm.supplier.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeactivateConfirm({ show: false, supplier: null });
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deactivateConfirm.supplier.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (supplier: WarehouseSupplier) => {
setToggleActiveSupplierId(supplier.id);
try {
const response = await apiFetch(`${API_BASE}/${supplier.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !supplier.is_active }),
await toggleActiveMutation.mutateAsync({
is_active: !supplier.is_active,
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(
supplier.is_active
? "Dodavatel byl deaktivován"
: "Dodavatel byl aktivován",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(
supplier.is_active
? "Dodavatel byl deaktivován"
: "Dodavatel byl aktivován",
);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setToggleActiveSupplierId(null);
}
};
@@ -216,33 +208,18 @@ export default function WarehouseSuppliers() {
>
<div>
<h1 className="admin-page-title">Dodavatelé</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? suppliers.length}{" "}
{pagination?.total === 1
? "dodavatel"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "dodavatelé"
: "dodavatelů"}
</p>
</div>
<div className="admin-page-actions">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat dodavatele..."
className="admin-form-input"
/>
</div>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -268,302 +245,290 @@ export default function WarehouseSuppliers() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
{suppliers.length === 0 && !search && (
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<p>Zatím nejsou žádní dodavatelé.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
<div className="admin-search-bar mb-4">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
Přidat prvního dodavatele
</button>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat dodavatele..."
className="admin-form-input"
/>
</div>
)}
{suppliers.length === 0 && search && (
<div className="admin-empty-state">
<p>Žádní dodavatelé pro &quot;{search}&quot;.</p>
</div>
)}
{suppliers.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>IČO</th>
<th>DIČ</th>
<th>Kontaktní osoba</th>
<th>E-mail</th>
<th>Telefon</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{suppliers.map((supplier) => (
<tr
key={supplier.id}
className={
!supplier.is_active ? "admin-table-row-inactive" : ""
}
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${debouncedSearch}-${page}-${suppliers.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{suppliers.length === 0 && !search && (
<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"
>
<td className="fw-500">{supplier.name}</td>
<td className="admin-mono">{supplier.ico || "—"}</td>
<td className="admin-mono">{supplier.dic || "—"}</td>
<td>{supplier.contact_person || "—"}</td>
<td>{supplier.email || "—"}</td>
<td className="admin-mono">{supplier.phone || "—"}</td>
<td>
<button
onClick={() => toggleActive(supplier)}
className={`admin-badge ${supplier.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<p>Zatím nejsou žádní dodavatelé.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat prvního dodavatele
</button>
</div>
)}
{suppliers.length === 0 && search && (
<div className="admin-empty-state">
<p>Žádní dodavatelé pro &quot;{search}&quot;.</p>
</div>
)}
{suppliers.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>IČO</th>
<th>DIČ</th>
<th>Kontaktní osoba</th>
<th>E-mail</th>
<th>Telefon</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{suppliers.map((supplier) => (
<tr
key={supplier.id}
className={
!supplier.is_active
? "admin-table-row-inactive"
: ""
}
>
{supplier.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(supplier)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<td className="fw-500">{supplier.name}</td>
<td className="admin-mono">{supplier.ico || "—"}</td>
<td className="admin-mono">{supplier.dic || "—"}</td>
<td>{supplier.contact_person || "—"}</td>
<td>{supplier.email || "—"}</td>
<td className="admin-mono">
{supplier.phone || "—"}
</td>
<td>
<button
onClick={() => toggleActive(supplier)}
className={`admin-badge ${supplier.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
<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>
<button
onClick={() =>
setDeactivateConfirm({
show: true,
supplier,
})
}
className={`admin-btn-icon ${supplier.is_active ? "danger" : ""}`}
title={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
aria-label={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
)}
{supplier.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(supplier)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
<button
onClick={() =>
setDeactivateConfirm({
show: true,
supplier,
})
}
className={`admin-btn-icon ${supplier.is_active ? "danger" : ""}`}
title={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
aria-label={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<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>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
{pagination && (
<Pagination pagination={pagination} onPageChange={setPage} />
)}
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(false)}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={saving}
>
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název dodavatele"
aria-invalid={!!errors.name}
/>
<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 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
</h2>
</div>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název dodavatele"
aria-invalid={!!errors.name}
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })}
className="admin-form-input"
placeholder="12345678"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.ico}
onChange={(e) =>
setForm({ ...form, ico: e.target.value })
}
className="admin-form-input"
placeholder="12345678"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })}
className="admin-form-input"
placeholder="CZ12345678"
/>
</FormField>
</div>
<FormField label="DIČ">
<input
type="text"
value={form.dic}
onChange={(e) =>
setForm({ ...form, dic: e.target.value })
}
className="admin-form-input"
placeholder="CZ12345678"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Kontaktní osoba">
<input
type="text"
value={form.contact_person}
onChange={(e) =>
setForm({
...form,
contact_person: e.target.value,
})
}
className="admin-form-input"
placeholder="Jan Novák"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Kontaktní osoba">
<input
type="text"
value={form.contact_person}
onChange={(e) =>
setForm({
...form,
contact_person: e.target.value,
})
}
className="admin-form-input"
placeholder="Jan Novák"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="admin-form-input"
placeholder="info@firma.cz"
/>
</FormField>
</div>
<FormField label="E-mail">
<input
type="email"
value={form.email}
onChange={(e) =>
setForm({ ...form, email: e.target.value })
}
className="admin-form-input"
placeholder="info@firma.cz"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Telefon">
<input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
className="admin-form-input"
placeholder="+420 123 456 789"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Telefon">
<input
type="tel"
value={form.phone}
onChange={(e) =>
setForm({ ...form, phone: e.target.value })
}
className="admin-form-input"
placeholder="+420 123 456 789"
/>
</FormField>
</div>
<FormField label="Adresa">
<textarea
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
className="admin-form-input"
rows={3}
placeholder="Ulice, město, PSČ"
/>
</FormField>
<FormField label="Adresa">
<textarea
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Ulice, město, PSČ"
/>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</FormModal>
{/* Deactivate/Delete Confirmation */}
<ConfirmModal