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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user