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

@@ -49,11 +49,11 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
@@ -61,6 +61,7 @@ import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -403,9 +404,13 @@ export default function OfferDetail() {
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
// Set to true right after a successful delete so the detail-page error
// effect doesn't fire "Nepodařilo se načíst nabídku" on top of the
// success toast (the 404 from a refetched-already-deleted row is
// expected here, not a real error).
const deletingRef = useRef(false);
useModalLock(showOrderModal);
// Note: FormModal applies useModalLock internally.
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
@@ -476,9 +481,12 @@ export default function OfferDetail() {
formInitializedRef.current = true;
}, [offerQuery.data, companySettings, hasPermission, id, emptyItem]);
// Redirect on offer fetch error (edit mode)
// Redirect on offer fetch error (edit mode).
// Suppress when deletingRef is set: the in-flight refetch of a row we
// just deleted will 404, and we don't want a "load failed" toast on
// top of the success toast.
useEffect(() => {
if (isEdit && offerQuery.isError) {
if (isEdit && offerQuery.isError && !deletingRef.current) {
alert.error("Nepodařilo se načíst nabídku");
navigate("/offers");
}
@@ -670,7 +678,7 @@ export default function OfferDetail() {
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -682,7 +690,7 @@ export default function OfferDetail() {
items,
sections,
});
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -713,7 +721,7 @@ export default function OfferDetail() {
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
// Without attachment: send as JSON (avoids multipart content-type issues)
// Without attachment: send as JSON
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -728,7 +736,7 @@ export default function OfferDetail() {
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -754,7 +762,7 @@ export default function OfferDetail() {
setInvalidateConfirm(false);
setOfferStatus("invalidated");
alert.success(result.message || "Nabídka byla zneplatněna");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -776,8 +784,12 @@ export default function OfferDetail() {
});
const result = await response.json();
if (result.success) {
// Mark the row as gone before the list invalidation refetches the
// detail key (which would 404 and trigger the error-toast effect).
deletingRef.current = true;
queryClient.removeQueries({ queryKey: ["offers", id] });
alert.success(result.message || "Nabídka byla smazána");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -1374,7 +1386,7 @@ export default function OfferDetail() {
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
@@ -1678,126 +1690,85 @@ export default function OfferDetail() {
</motion.div>
{/* Order modal */}
<AnimatePresence>
{showOrderModal && (
<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={() => !creatingOrder && setShowOrderModal(false)}
<FormModal
isOpen={showOrderModal}
onClose={() => !creatingOrder && setShowOrderModal(false)}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={creatingOrder}
>
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
<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">Vytvořit objednávku</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
!creatingOrder &&
handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small
className="admin-form-hint"
style={{ marginTop: "0.25rem" }}
>
Max 10 MB
</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
onClick={() => setShowOrderModal(false)}
className="admin-btn admin-btn-secondary"
disabled={creatingOrder}
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
Zrušit
</button>
<button
onClick={handleCreateOrder}
className="admin-btn admin-btn-primary"
disabled={creatingOrder || !customerOrderNumber.trim()}
>
{creatingOrder ? "Vytváření..." : "Vytvořit"}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: "0.25rem" }}>
Max 10 MB
</small>
</FormField>
</div>
</FormModal>
<ConfirmModal
isOpen={invalidateConfirm}