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

@@ -5,8 +5,9 @@ import {
useParams,
Link,
} from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -349,6 +350,7 @@ export default function InvoiceDetail() {
{
_key: "inv-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
@@ -400,7 +402,6 @@ export default function InvoiceDetail() {
}, []);
// ─── TanStack Query ───
const queryClient = useQueryClient();
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
@@ -762,6 +763,34 @@ export default function InvoiceDetail() {
}, [items, form.apply_vat]);
// ─── Create/Edit mode: submit ───
const saveMutation = useApiMutation<
Record<string, unknown>,
{ invoice_id: number }
>({
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["invoices", "orders"],
onSuccess: (data) => {
const invoiceId = isEdit ? Number(id) : data.invoice_id;
// PDF binary generation — KEEP as raw apiFetch
void apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
},
});
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "PUT",
invalidate: ["invoices", "orders"],
});
const invoiceDeleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "DELETE",
invalidate: ["invoices", "orders"],
});
const handleCreateSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -779,7 +808,7 @@ export default function InvoiceDetail() {
setSaving(true);
try {
const payload: any = {
const payload: Record<string, unknown> = {
...form,
due_date: computedDueDate || form.due_date,
items: items
@@ -791,48 +820,21 @@ export default function InvoiceDetail() {
};
if (isEdit) payload.invoice_number = invoiceNumber;
const url = isEdit
? `${API_BASE}/invoices/${id}`
: `${API_BASE}/invoices`;
const method = isEdit ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
if (!isEdit) clearDraft();
const invoiceId = isEdit ? id : result.data.invoice_id;
await apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
alert.success(
result.message ||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/invoices/${result.data.invoice_id}`);
}
} else {
alert.error(
result.error ||
(isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu"),
);
const data = await saveMutation.mutateAsync(payload);
if (!isEdit) clearDraft();
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items });
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(
e instanceof Error
? e.message
: isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu",
);
} finally {
setSaving(false);
}
@@ -841,25 +843,14 @@ export default function InvoiceDetail() {
// ─── Edit mode: status change ───
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
setStatusChanging(statusConfirm.status);
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: statusConfirm.status }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
@@ -893,21 +884,11 @@ export default function InvoiceDetail() {
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/invoices");
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
} catch {
alert.error("Chyba připojení");
await invoiceDeleteMutation.mutateAsync(undefined);
alert.success("Faktura byla smazána");
navigate("/invoices");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
@@ -1101,7 +1082,7 @@ export default function InvoiceDetail() {
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
</div>
{invoice.items?.length > 0 ? (
{invoice.items && invoice.items.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>