fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -300,7 +300,7 @@ export default function Attendance() {
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
invalidate: ["attendance", "leave-requests", "leave", "users"],
});
const [submitting, setSubmitting] = useState(false);
@@ -323,6 +323,10 @@ export default function Attendance() {
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
// actually advances instead of freezing at first paint.
const [now, setNow] = useState(() => new Date());
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -332,6 +336,11 @@ export default function Attendance() {
};
}, []);
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(id);
}, []);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
@@ -475,10 +484,20 @@ export default function Attendance() {
}
};
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
// would parse as UTC midnight; reading it back with local getDay()/getDate()
// can land on the wrong calendar day in the evening Prague window.
const parseLocalDate = (s: string): Date | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
};
const calculateBusinessDays = (from: string, to: string) => {
if (!from || !to) return 0;
const start = new Date(from);
const end = new Date(to);
const start = parseLocalDate(from);
const end = parseLocalDate(to);
if (!start || !end) return 0;
if (end < start) return 0;
let days = 0;
const current = new Date(start);
@@ -673,7 +692,7 @@ export default function Attendance() {
lineHeight: 1,
}}
>
{new Date().toLocaleTimeString("cs-CZ", {
{now.toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}

View File

@@ -168,8 +168,6 @@ export default function AttendanceBalances() {
userName: string;
}>({ show: false, userId: null, userName: "" });
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
@@ -199,6 +197,8 @@ export default function AttendanceBalances() {
},
});
if (!hasPermission("attendance.balances")) return <Forbidden />;
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
@@ -767,7 +767,7 @@ export default function AttendanceBalances() {
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
vacation_total: parseFloat(e.target.value) || 0,
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
@@ -781,7 +781,7 @@ export default function AttendanceBalances() {
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
vacation_used: parseFloat(e.target.value) || 0,
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
@@ -795,7 +795,7 @@ export default function AttendanceBalances() {
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
sick_used: parseFloat(e.target.value) || 0,
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}

View File

@@ -187,7 +187,7 @@ export default function AttendanceCreate() {
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
leave_hours: parseFloat(e.target.value) || 0,
})
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}

View File

@@ -184,6 +184,13 @@ export default function AttendanceLocation() {
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show the spinner while the record is still loading — this must precede the
// `!record` null-return below, otherwise a pending query (record === undefined)
// renders a blank page and this branch is dead.
if (isPending) {
return <LoadingState />;
}
if (!record) {
return null;
}
@@ -196,10 +203,6 @@ export default function AttendanceLocation() {
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
if (isPending) {
return <LoadingState />;
}
return (
<PageEnter>
{/* Header */}

View File

@@ -182,10 +182,6 @@ export default function AuditLog() {
const logs: AuditLogEntry[] = logsData?.data ?? [];
const pagination = logsData?.pagination ?? null;
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const cleanupMutation = useApiMutation<
{ days: number; confirm?: string },
{ message?: string; error?: string }
@@ -199,6 +195,10 @@ export default function AuditLog() {
},
});
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);

View File

@@ -1309,7 +1309,7 @@ export default function CompanySettings({
onChange={(e) =>
updateField("default_vat_rate", Number(e.target.value))
}
inputProps={{ min: 0, step: 1 }}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</Field>
<Field label="Dostupné měny">

View File

@@ -51,6 +51,7 @@ import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
Card,
@@ -69,6 +70,33 @@ import {
const API_BASE = "/api/admin";
/**
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
* used. We build the Date from the local year/month/day parts (so it's a local
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
* it back with local getters — never via `toISOString()`, which would shift a
* day during the evening Prague window. See utils/formatters.todayLocalStr.
*/
function addDaysLocalStr(days: number, base?: string): string {
let y: number, m: number, d: number;
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
if (parts) {
y = Number(parts[1]);
m = Number(parts[2]) - 1;
d = Number(parts[3]);
} else {
const now = new Date();
y = now.getFullYear();
m = now.getMonth();
d = now.getDate();
}
const result = new Date(y, m, d + days);
const mm = String(result.getMonth() + 1).padStart(2, "0");
const dd = String(result.getDate()).padStart(2, "0");
return `${result.getFullYear()}-${mm}-${dd}`;
}
const STATUS_LABELS: Record<string, string> = {
issued: "Vystavena",
paid: "Zaplacena",
@@ -491,7 +519,7 @@ export default function InvoiceDetail() {
customer_name: "",
order_id: fromOrderId ? Number(fromOrderId) : null,
issue_date: todayLocalStr(),
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
due_date: addDaysLocalStr(14),
tax_date: todayLocalStr(),
currency: "CZK",
apply_vat: 1,
@@ -641,15 +669,9 @@ export default function InvoiceDetail() {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
issue_date: inv.issue_date
? new Date(inv.issue_date).toISOString().split("T")[0]
: "",
due_date: inv.due_date
? new Date(inv.due_date).toISOString().split("T")[0]
: "",
tax_date: inv.tax_date
? new Date(inv.tax_date).toISOString().split("T")[0]
: "",
issue_date: normalizeDateStr(inv.issue_date),
due_date: normalizeDateStr(inv.due_date),
tax_date: normalizeDateStr(inv.tax_date),
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
@@ -713,7 +735,7 @@ export default function InvoiceDetail() {
bankAccountsQuery.isLoading,
bankAccountsQuery.data,
customersQuery.isLoading,
]); // eslint-disable-line react-hooks/exhaustive-deps
]);
// Create mode: populate form from query data
useEffect(() => {
@@ -791,7 +813,7 @@ export default function InvoiceDetail() {
orderDataQuery.data,
companySettings,
bankAccounts,
]); // eslint-disable-line react-hooks/exhaustive-deps
]);
// Capture initial snapshot for dirty-checking once data sync completes.
// Edit mode: captured inside the sync effect from raw query data.
@@ -817,9 +839,7 @@ export default function InvoiceDetail() {
const computedDueDate = useMemo(() => {
if (!form.issue_date) return "";
const d = new Date(form.issue_date);
d.setDate(d.getDate() + dueDays);
return d.toISOString().split("T")[0];
return addDaysLocalStr(dueDays, form.issue_date);
}, [form.issue_date, dueDays]);
// ─── Create mode: customer filtering ───

View File

@@ -10,7 +10,13 @@ import CircularProgress from "@mui/material/CircularProgress";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import {
formatCurrency,
formatDate,
czechPlural,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import useTableSort from "../hooks/useTableSort";
import {
@@ -499,12 +505,14 @@ export default function Invoices() {
};
// Per-row overdue tint (replaces offers-expired-row).
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
// to avoid the UTC-vs-local off-by-one near midnight Prague.
const rowSx = (inv: Invoice) => {
const isOverdue =
inv.status === "overdue" ||
(inv.status === "issued" &&
inv.due_date &&
new Date(inv.due_date) < new Date(new Date().toDateString()));
!!inv.due_date &&
normalizeDateStr(inv.due_date) < todayLocalStr());
if (isOverdue) {
return {
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",

View File

@@ -155,8 +155,6 @@ export default function LeaveApproval() {
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
if (!hasPermission("attendance.approve")) return <Forbidden />;
const approveMutation = useApiMutation<
{ id: number; status: "approved" },
unknown
@@ -184,6 +182,8 @@ export default function LeaveApproval() {
},
});
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
if (!approveModal.request) return;
try {

View File

@@ -99,10 +99,20 @@ export default function Login() {
e.preventDefault();
if (!totpCode.trim()) return;
// Defensive: the 2FA form only renders after a loginToken is issued, but
// guard against a null token (e.g. server returns requires2FA without one)
// rather than passing `null!` through to verify2FA.
if (!loginToken) {
alert.error("Relace vypršela, přihlaste se prosím znovu");
setShow2FA(false);
setTotpCode("");
return;
}
setLoading(true);
const result = await verify2FA(
loginToken!,
loginToken,
totpCode.trim(),
remember,
useBackupCode,
@@ -131,6 +141,7 @@ export default function Login() {
setLoading,
setShake,
setTotpCode,
setShow2FA,
setAnimatingOut,
],
);

View File

@@ -758,7 +758,7 @@ export default function OfferDetail() {
signal: controller.signal,
}).catch(() => {});
};
}, [isEdit, id, isLockedByOther, isInvalidated]);
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {

View File

@@ -236,7 +236,7 @@ export default function OffersCustomers() {
return order.filter((key) => {
if (key.startsWith("custom_")) {
const idx = parseInt(key.split("_")[1]);
const idx = parseInt(key.split("_")[1], 10);
return idx < customFields.length;
}
return true;
@@ -255,7 +255,7 @@ export default function OffersCustomers() {
const getFieldDisplayName = (key: string) => {
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key];
if (key.startsWith("custom_")) {
const idx = parseInt(key.split("_")[1]);
const idx = parseInt(key.split("_")[1], 10);
const cf = customFields[idx];
if (cf)
return cf.name
@@ -672,7 +672,7 @@ export default function OffersCustomers() {
.filter((k) => k !== key)
.map((k) => {
if (k.startsWith("custom_")) {
const ki = parseInt(k.split("_")[1]);
const ki = parseInt(k.split("_")[1], 10);
if (ki > idx) return `custom_${ki - 1}`;
}
return k;

View File

@@ -176,8 +176,6 @@ export default function OrderDetail() {
return { subtotal, vatAmount, total: subtotal + vatAmount };
}, [order]);
if (!hasPermission("orders.view")) return <Forbidden />;
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
@@ -199,6 +197,8 @@ export default function OrderDetail() {
invalidate: ["orders", "invoices"],
});
if (!hasPermission("orders.view")) return <Forbidden />;
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
@@ -462,8 +462,8 @@ export default function OrderDetail() {
</Button>
)}
{hasPermission("orders.edit") &&
order.valid_transitions?.filter((s) => s !== "stornovana")
.length! > 0 &&
(order.valid_transitions?.filter((s) => s !== "stornovana")
.length ?? 0) > 0 &&
order
.valid_transitions!.filter((s) => s !== "stornovana")
.map((status) => (

View File

@@ -174,8 +174,6 @@ export default function ProjectDetail() {
}
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission("projects.view")) return <Forbidden />;
const projectSaveMutation = useApiMutation<
{
name: string;
@@ -212,6 +210,8 @@ export default function ProjectDetail() {
invalidate: ["projects", "warehouse"],
});
if (!hasPermission("projects.view")) return <Forbidden />;
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));

View File

@@ -9,6 +9,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
projectListOptions,
@@ -117,6 +118,7 @@ export default function Projects() {
const { sort, order, handleSort } = useTableSort("project_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
@@ -216,7 +218,7 @@ export default function Projects() {
isPending,
isFetching,
} = usePaginatedQuery<Project>(
projectListOptions({ search, sort, order, page }),
projectListOptions({ search: debouncedSearch, sort, order, page }),
);
if (!hasPermission("projects.view")) return <Forbidden />;

View File

@@ -10,6 +10,7 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import useTableSort from "../hooks/useTableSort";
import {
companySettingsOptions,
@@ -276,7 +277,14 @@ export default function ReceivedInvoices({
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = listQuery.data?.data ?? [];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Track first successful load (used to suppress the skeleton on later
// refetches). Set in an effect rather than during render to avoid a
// render-phase ref mutation.
const hasData = !!(listQuery.data || statsQuery.data);
useEffect(() => {
if (hasData) hasLoadedOnce.current = true;
}, [hasData]);
// Derive stats from query
const stats = statsQuery.data ?? null;
@@ -446,12 +454,11 @@ export default function ReceivedInvoices({
}
};
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];
};
// Seed edit-form date inputs from stored values. Use normalizeDateStr (pure
// string slice) instead of new Date(...).toISOString() — the latter converts
// to UTC and is a day early in the late-evening Prague window.
const toDateInput = (d: string | null | undefined): string =>
normalizeDateStr(d);
const openEdit = (inv: ReceivedInvoice) => {
setEditInvoice({

View File

@@ -311,11 +311,6 @@ export default function Settings() {
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
// ── Early return after all hooks ──
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
const saveSystemSettingsMutation = useApiMutation<
typeof sysForm,
{ message?: string; error?: string }
@@ -400,6 +395,11 @@ export default function Settings() {
},
});
// ── Early return after all hooks ──
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
const handleSaveSystemSettings = async () => {
try {
await saveSystemSettingsMutation.mutateAsync(sysForm);

View File

@@ -185,8 +185,6 @@ export default function Trips() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0);
if (!hasPermission("trips.record")) return <Forbidden />;
const submitMutation = useApiMutation<
TripForm,
{ message?: string; error?: string }
@@ -214,6 +212,8 @@ export default function Trips() {
},
});
if (!hasPermission("trips.record")) return <Forbidden />;
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0);
@@ -284,7 +284,7 @@ export default function Trips() {
if (
form.start_km &&
form.end_km &&
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
parseInt(String(form.end_km), 10) <= parseInt(String(form.start_km), 10)
) {
newErrors.end_km = "Musí být větší než počáteční";
}
@@ -312,8 +312,8 @@ export default function Trips() {
};
const calculateDistance = (): number => {
const start = parseInt(String(form.start_km)) || 0;
const end = parseInt(String(form.end_km)) || 0;
const start = parseInt(String(form.start_km), 10) || 0;
const end = parseInt(String(form.end_km), 10) || 0;
return end > start ? end - start : 0;
};

View File

@@ -228,8 +228,6 @@ export default function TripsAdmin() {
);
const trips = (tripsData ?? []).map(mapTrip);
if (!hasPermission("trips.manage")) return <Forbidden />;
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
// NOT a wrapper that nests the body. id is captured in the URL closure.
@@ -265,6 +263,8 @@ export default function TripsAdmin() {
},
});
if (!hasPermission("trips.manage")) return <Forbidden />;
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);
setEditForm({

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -19,6 +18,7 @@ import {
Field,
TextField,
SwitchField,
StatusChip,
LoadingState,
PageEnter,
type DataColumn,
@@ -261,12 +261,10 @@ export default function Vehicles() {
key: "status",
header: "Stav",
render: (v) => (
<Chip
<StatusChip
label={v.is_active ? "Aktivní" : "Neaktivní"}
size="small"
color={v.is_active ? "success" : "default"}
onClick={() => toggleActive(v)}
sx={{ cursor: "pointer", fontWeight: 600 }}
/>
),
},
@@ -401,7 +399,10 @@ export default function Vehicles() {
value={form.initial_km}
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
onChange={(e) =>
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
setForm({
...form,
initial_km: parseInt(e.target.value, 10) || 0,
})
}
/>
</Field>

View File

@@ -10,6 +10,7 @@ import {
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type MovementLogRow,
} from "../lib/queries/warehouse";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
@@ -72,15 +73,8 @@ interface BelowMinRow {
min_quantity: number;
}
interface MovementRow {
_idx: number;
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
supplier_or_project: string;
}
// Dashboard movement row = the shared lib row plus a stable list index for rowKey.
type MovementRow = MovementLogRow & { _idx: number };
export default function Warehouse() {
const { hasPermission } = useAuth();

View File

@@ -99,7 +99,6 @@ export default function WarehouseCategories() {
show: boolean;
category: WarehouseCategory | null;
}>({ show: false, category: null });
const [saving, setSaving] = useState(false);
const submitMutation = useApiMutation<CategoryForm, void>({
url: () =>
@@ -156,13 +155,10 @@ export default function WarehouseCategories() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -274,7 +270,7 @@ export default function WarehouseCategories() {
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
loading={saving}
loading={submitMutation.isPending}
>
<Field label="Název" required error={errors.name}>
<TextField
@@ -302,12 +298,13 @@ export default function WarehouseCategories() {
<TextField
type="number"
value={String(form.sort_order)}
onChange={(e) =>
onChange={(e) => {
const n = Number(e.target.value);
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
sort_order: Number.isFinite(n) ? n : 0,
});
}}
inputProps={{ min: 0 }}
/>
</Field>

View File

@@ -122,7 +122,12 @@ export default function WarehouseInventory() {
header: "Položky",
width: "20%",
mono: true,
render: (s) => String(s.items?.length ?? 0),
// List endpoint returns `_count` (not `items`), matching the issues/receipts lists.
render: (s) =>
String(
(s as WarehouseInventorySession & { _count?: { items: number } })
._count?.items ?? 0,
),
},
];

View File

@@ -67,13 +67,9 @@ export default function WarehouseInventoryDetail() {
error,
} = useQuery(warehouseInventoryDetailOptions(id));
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const confirmMutation = useApiMutation<void, void>({
url: () =>
session
? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`
: "/api/admin/warehouse/inventory-sessions/0/confirm",
const confirmMutation = useApiMutation<number, void>({
url: (sessionId) =>
`/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -82,10 +78,13 @@ export default function WarehouseInventoryDetail() {
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const handleConfirm = async () => {
if (!session) return;
try {
await confirmMutation.mutateAsync(undefined);
await confirmMutation.mutateAsync(session.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}

View File

@@ -22,6 +22,12 @@ interface InventoryItem {
actual_qty: number;
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const BackIcon = (
<svg
width="20"
@@ -80,17 +86,6 @@ export default function WarehouseInventoryForm() {
const { data: locations } = useQuery(warehouseLocationListOptions());
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const validate = (): boolean => {
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
alert.error("Přidejte alespoň jednu položku");
return false;
}
return true;
};
const createMutation = useApiMutation<
{
notes: string | null;
@@ -111,6 +106,24 @@ export default function WarehouseInventoryForm() {
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const validate = (): boolean => {
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
alert.error("Přidejte alespoň jednu položku");
return false;
}
for (const l of validItems) {
if (!Number.isFinite(l.actual_qty) || l.actual_qty < 0) {
alert.error("Skutečné množství musí být platné číslo");
return false;
}
}
return true;
};
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
@@ -246,7 +259,7 @@ export default function WarehouseInventoryForm() {
type="number"
value={item.actual_qty || ""}
onChange={(e) =>
updateItem(index, "actual_qty", Number(e.target.value))
updateItem(index, "actual_qty", parseDecimal(e.target.value))
}
inputProps={{
min: 0,

View File

@@ -70,15 +70,8 @@ export default function WarehouseIssueDetail() {
error,
} = useQuery(warehouseIssueDetailOptions(id));
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const confirmMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
const confirmMutation = useApiMutation<number, void>({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -86,11 +79,8 @@ export default function WarehouseIssueDetail() {
},
});
const cancelMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/cancel`
: "/api/admin/warehouse/issues/0/cancel",
const cancelMutation = useApiMutation<number, void>({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -99,10 +89,15 @@ export default function WarehouseIssueDetail() {
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const handleConfirm = async () => {
if (!issue) return;
try {
await confirmMutation.mutateAsync(undefined);
await confirmMutation.mutateAsync(issue.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
@@ -111,7 +106,7 @@ export default function WarehouseIssueDetail() {
const handleCancel = async () => {
if (!issue) return;
try {
await cancelMutation.mutateAsync(undefined);
await cancelMutation.mutateAsync(issue.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
@@ -315,8 +310,8 @@ export default function WarehouseIssueDetail() {
{iss.project ? (
<Typography
variant="body2"
component="a"
href={`/projects/${iss.project.id}`}
component={RouterLink}
to={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,

View File

@@ -264,6 +264,28 @@ export default function WarehouseIssueForm() {
}
}, [isEdit, issue]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const confirmMutation = useApiMutation<number, void>({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.operate")) return <Forbidden />;
const addItem = () => {
@@ -332,31 +354,6 @@ export default function WarehouseIssueForm() {
})),
});
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
const confirmMutation = useApiMutation<void, void>({
url: () =>
confirmIssueId
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const saveIssue = async (): Promise<number | null> => {
try {
const data = await saveMutation.mutateAsync(buildPayload());
@@ -370,13 +367,10 @@ export default function WarehouseIssueForm() {
};
const confirmIssue = async (issueId: number) => {
setConfirmIssueId(issueId);
try {
await confirmMutation.mutateAsync(undefined);
await confirmMutation.mutateAsync(issueId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setConfirmIssueId(null);
}
};

View File

@@ -154,13 +154,6 @@ export default function WarehouseItemDetail() {
useModalLock(editing);
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canManage = hasPermission("warehouse.manage");
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
setForm((prev) => ({ ...prev, [field]: value }));
const saveMutation = useApiMutation<
Record<string, unknown>,
{ id?: number; message?: string }
@@ -187,6 +180,14 @@ export default function WarehouseItemDetail() {
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canManage = hasPermission("warehouse.manage");
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
setForm((prev) => ({ ...prev, [field]: value }));
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Zadejte název položky";

View File

@@ -233,13 +233,8 @@ export default function WarehouseLocations() {
}
const total = locations.length;
const subtitle = `${total} ${
total === 1
? "umístění"
: total >= 2 && total <= 4
? "umístění"
: "umístění"
}`;
// "umístění" is invariant across grammatical number in Czech — no plural form.
const subtitle = `${total} umístění`;
const columns: DataColumn<WarehouseLocation>[] = [
{

View File

@@ -94,15 +94,8 @@ export default function WarehouseReceiptDetail() {
error,
} = useQuery(warehouseReceiptDetailOptions(id));
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const confirmMutation = useApiMutation<void, void>({
url: () =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/confirm`
: "/api/admin/warehouse/receipts/0/confirm",
const confirmMutation = useApiMutation<number, void>({
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -110,11 +103,8 @@ export default function WarehouseReceiptDetail() {
},
});
const cancelMutation = useApiMutation<void, void>({
url: () =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/cancel`
: "/api/admin/warehouse/receipts/0/cancel",
const cancelMutation = useApiMutation<number, void>({
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/cancel`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -124,13 +114,11 @@ export default function WarehouseReceiptDetail() {
});
const deleteAttachmentMutation = useApiMutation<
{ attachmentId: number },
{ receiptId: number; attachmentId: number },
void
>({
url: ({ attachmentId }) =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`
: `/api/admin/warehouse/receipts/0/attachments/0`,
url: ({ receiptId, attachmentId }) =>
`/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: () => {
@@ -138,10 +126,15 @@ export default function WarehouseReceiptDetail() {
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const handleConfirm = async () => {
if (!receipt) return;
try {
await confirmMutation.mutateAsync(undefined);
await confirmMutation.mutateAsync(receipt.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
@@ -150,7 +143,7 @@ export default function WarehouseReceiptDetail() {
const handleCancel = async () => {
if (!receipt) return;
try {
await cancelMutation.mutateAsync(undefined);
await cancelMutation.mutateAsync(receipt.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
@@ -159,7 +152,10 @@ export default function WarehouseReceiptDetail() {
const handleDeleteAttachment = async (attachmentId: number) => {
if (!receipt) return;
try {
await deleteAttachmentMutation.mutateAsync({ attachmentId });
await deleteAttachmentMutation.mutateAsync({
receiptId: receipt.id,
attachmentId,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}

View File

@@ -200,6 +200,75 @@ export default function WarehouseReceiptForm() {
}
}, [isEdit, receipt]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/receipts/${id}`
: "/api/admin/warehouse/receipts",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const confirmMutation = useApiMutation<number, void>({
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Doklad byl potvrzen");
},
});
const handleFileUpload = useCallback(
async (files: FileList | File[]) => {
if (!isEdit || !id) return;
setUploadingFiles(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
const response = await apiFetch(
`/api/admin/warehouse/receipts/${id}/attachments`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (!result.success) {
alert.error(
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
);
}
}
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success("Soubory byly nahrány");
} catch {
alert.error("Chyba připojení");
} finally {
setUploadingFiles(false);
}
},
[id, isEdit, alert, queryClient],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files);
}
},
[handleFileUpload],
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.operate")) return <Forbidden />;
const addItem = () => {
@@ -266,31 +335,6 @@ export default function WarehouseReceiptForm() {
})),
});
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/receipts/${id}`
: "/api/admin/warehouse/receipts",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const [confirmReceiptId, setConfirmReceiptId] = useState<number | null>(null);
const confirmMutation = useApiMutation<void, void>({
url: () =>
confirmReceiptId
? `/api/admin/warehouse/receipts/${confirmReceiptId}/confirm`
: "/api/admin/warehouse/receipts/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Doklad byl potvrzen");
},
});
const saveReceipt = async (): Promise<number | null> => {
try {
const data = await saveMutation.mutateAsync(buildPayload());
@@ -304,13 +348,10 @@ export default function WarehouseReceiptForm() {
};
const confirmReceipt = async (receiptId: number) => {
setConfirmReceiptId(receiptId);
try {
await confirmMutation.mutateAsync(undefined);
await confirmMutation.mutateAsync(receiptId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setConfirmReceiptId(null);
}
};
@@ -342,53 +383,6 @@ export default function WarehouseReceiptForm() {
}
};
const handleFileUpload = useCallback(
async (files: FileList | File[]) => {
if (!isEdit || !id) return;
setUploadingFiles(true);
try {
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
const response = await apiFetch(
`/api/admin/warehouse/receipts/${id}/attachments`,
{
method: "POST",
body: formData,
},
);
const result = await response.json();
if (!result.success) {
alert.error(
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
);
}
}
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success("Soubory byly nahrány");
} catch {
alert.error("Chyba připojení");
} finally {
setUploadingFiles(false);
}
},
[id, isEdit, alert, queryClient],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files);
}
},
[handleFileUpload],
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
if (isEdit && isPending) {
return <LoadingState />;
}

View File

@@ -8,11 +8,14 @@ import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseCategoryListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type MovementLogRow,
} from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
Alert,
Button,
Card,
DataTable,
@@ -43,16 +46,6 @@ interface ProjectConsumptionRow {
total_value: number;
}
interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
const TABS: TabDef[] = [
{ value: "stock-status", label: "Stav zásob" },
{ value: "project-consumption", label: "Spotřeba projektů" },
@@ -260,34 +253,36 @@ function ProjectConsumptionTab({
name: string;
}[]) ?? [];
const [data, setData] = useState<ProjectConsumptionRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
// Filters applied on "Zobrazit"; the query key carries them so results land in
// the ["warehouse"] cache and refresh on warehouse mutations.
const [applied, setApplied] = useState<{
projectId: number | "";
dateFrom: string;
dateTo: string;
} | null>(null);
const handleFetch = async () => {
setLoading(true);
setFetched(true);
try {
const {
data,
isFetching: loading,
error,
} = useQuery({
queryKey: ["warehouse", "project-consumption", applied],
enabled: applied !== null,
queryFn: () => {
const params = new URLSearchParams();
if (projectId) params.set("project_id", String(projectId));
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
if (applied?.projectId)
params.set("project_id", String(applied.projectId));
if (applied?.dateFrom) params.set("date_from", applied.dateFrom);
if (applied?.dateTo) params.set("date_to", applied.dateTo);
const qs = params.toString();
const response = await apiFetch(
return jsonQuery<ProjectConsumptionRow[]>(
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
);
const result = await response.json();
if (result.success) {
setData(result.data ?? []);
} else {
setData([]);
}
} catch {
setData([]);
} finally {
setLoading(false);
}
};
},
});
const fetched = applied !== null;
const handleFetch = () => setApplied({ projectId, dateFrom, dateTo });
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
{
@@ -352,7 +347,15 @@ function ProjectConsumptionTab({
{fetched && loading && <LoadingState />}
{fetched && !loading && data && (
{fetched && !loading && error && (
<Alert severity="error">
{error instanceof Error
? error.message
: "Nepodařilo se načíst spotřebu projektů"}
</Alert>
)}
{fetched && !loading && !error && data && (
<Card>
<DataTable<ProjectConsumptionRow & { _idx: number }>
columns={columns}
@@ -383,34 +386,29 @@ function MovementLogTab({
dateTo: string;
setDateTo: (v: string) => void;
}) {
const [data, setData] = useState<MovementLogRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
// Filters applied on "Zobrazit"; uses the shared movement-log query option so
// results land in the ["warehouse"] cache and refresh on warehouse mutations.
const [applied, setApplied] = useState<{
type: string;
dateFrom: string;
dateTo: string;
} | null>(null);
const handleFetch = async () => {
setLoading(true);
setFetched(true);
try {
const params = new URLSearchParams();
if (type) params.set("type", type);
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
const qs = params.toString();
const response = await apiFetch(
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
);
const result = await response.json();
if (result.success) {
setData(result.data ?? []);
} else {
setData([]);
}
} catch {
setData([]);
} finally {
setLoading(false);
}
};
const {
data,
isFetching: loading,
error,
} = useQuery({
...warehouseMovementLogOptions({
type: applied?.type || undefined,
date_from: applied?.dateFrom || undefined,
date_to: applied?.dateTo || undefined,
}),
enabled: applied !== null,
});
const fetched = applied !== null;
const handleFetch = () => setApplied({ type, dateFrom, dateTo });
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
@@ -423,7 +421,7 @@ function MovementLogTab({
header: "Datum",
width: "12%",
mono: true,
render: (row) => row.date,
render: (row) => formatDate(row.date),
},
{
key: "type",
@@ -501,7 +499,15 @@ function MovementLogTab({
{fetched && loading && <LoadingState />}
{fetched && !loading && data && (
{fetched && !loading && error && (
<Alert severity="error">
{error instanceof Error
? error.message
: "Nepodařilo se načíst pohyby"}
</Alert>
)}
{fetched && !loading && !error && data && (
<Card>
<DataTable<MovementLogRow & { _idx: number }>
columns={columns}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -9,12 +9,12 @@ import IconButton from "@mui/material/IconButton";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import ItemPicker from "../components/warehouse/ItemPicker";
import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters";
import {
warehouseReservationListOptions,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import { projectListOptions } from "../lib/queries/projects";
import {
Button,
@@ -106,11 +106,17 @@ interface ReservationForm {
notes: string;
}
interface CreateReservationPayload {
item_id: number;
project_id: number;
quantity: number;
notes: string | null;
}
export default function WarehouseReservations() {
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
@@ -148,6 +154,29 @@ export default function WarehouseReservations() {
}),
);
const createMutation = useApiMutation<CreateReservationPayload, void>({
url: () => "/api/admin/warehouse/reservations",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setShowCreateModal(false);
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
alert.success("Rezervace byla vytvořena");
},
});
const cancelMutation = useApiMutation<number, void>({
url: (reservationId) =>
`/api/admin/warehouse/reservations/${reservationId}/cancel`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setCancelTarget(null);
alert.success("Rezervace byla zrušena");
},
});
// All hooks above this line — permission gate is the last thing before render.
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
@@ -161,33 +190,25 @@ export default function WarehouseReservations() {
const hasActiveFilters = statusFilter || projectId;
const handleCreate = async () => {
if (!form.item_id || !form.project_id || form.quantity <= 0) {
if (
!form.item_id ||
!form.project_id ||
!Number.isFinite(form.quantity) ||
form.quantity <= 0
) {
alert.error("Vyplňte položku, projekt a množství");
return;
}
setSaving(true);
try {
const response = await apiFetch("/api/admin/warehouse/reservations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
item_id: form.item_id,
project_id: form.project_id,
quantity: form.quantity,
notes: form.notes.trim() || null,
}),
await createMutation.mutateAsync({
item_id: form.item_id,
project_id: form.project_id,
quantity: form.quantity,
notes: form.notes.trim() || null,
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setShowCreateModal(false);
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
alert.success("Rezervace byla vytvořena");
} else {
alert.error(result.error || "Nepodařilo se vytvořit rezervaci");
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -197,20 +218,9 @@ export default function WarehouseReservations() {
if (!cancelTarget) return;
setCancelling(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setCancelTarget(null);
alert.success("Rezervace byla zrušena");
} else {
alert.error(result.error || "Nepodařilo se zrušit rezervaci");
}
} catch {
alert.error("Chyba připojení");
await cancelMutation.mutateAsync(cancelTarget.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setCancelling(false);
}
@@ -451,12 +461,13 @@ export default function WarehouseReservations() {
<TextField
type="number"
value={form.quantity || ""}
onChange={(e) =>
onChange={(e) => {
const n = Number(e.target.value);
setForm((prev) => ({
...prev,
quantity: Number(e.target.value),
}))
}
quantity: Number.isFinite(n) ? n : 0,
}));
}}
inputProps={{ min: 0, step: 0.001 }}
/>
</Field>

View File

@@ -129,10 +129,6 @@ 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);
const submitMutation = useApiMutation<
SupplierForm,
@@ -158,14 +154,14 @@ export default function WarehouseSuppliers() {
},
});
// id is captured from the mutation variable (built into the URL), so it stays
// correct even when called immediately after a state update. The non-strict
// UpdateSupplierSchema strips the `id` field from the body.
const toggleActiveMutation = useApiMutation<
{ is_active: boolean },
{ id: number; is_active: boolean },
{ message?: string }
>({
url: () => {
if (!toggleActiveSupplierId) return API_BASE;
return `${API_BASE}/${toggleActiveSupplierId}`;
},
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
});
@@ -210,13 +206,10 @@ export default function WarehouseSuppliers() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -231,9 +224,9 @@ export default function WarehouseSuppliers() {
};
const toggleActive = async (supplier: WarehouseSupplier) => {
setToggleActiveSupplierId(supplier.id);
try {
await toggleActiveMutation.mutateAsync({
id: supplier.id,
is_active: !supplier.is_active,
});
alert.success(
@@ -243,8 +236,6 @@ export default function WarehouseSuppliers() {
);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setToggleActiveSupplierId(null);
}
};
@@ -403,7 +394,7 @@ export default function WarehouseSuppliers() {
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={saving}
loading={submitMutation.isPending}
>
<Field label="Název" required error={errors.name}>
<TextField