Files
app/src/admin/pages/WarehouseReservations.tsx
BOHA 519edce373 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>
2026-06-09 06:45:26 +02:00

501 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import ItemPicker from "../components/warehouse/ItemPicker";
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,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const PER_PAGE = 20;
const STATUS_COLOR: Record<
string,
"success" | "error" | "warning" | "info" | "default"
> = {
ACTIVE: "success",
FULFILLED: "info",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
ACTIVE: "Aktivní",
FULFILLED: "Splněna",
CANCELLED: "Zrušena",
};
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const IssueIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
);
const CancelIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
interface ReservationForm {
item_id: number | null;
project_id: number | null;
quantity: number;
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 [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
const [projectId, setProjectId] = useState<number | "">("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ReservationForm>({
item_id: null,
project_id: null,
quantity: 0,
notes: "",
});
const [cancelTarget, setCancelTarget] = useState<WarehouseReservation | null>(
null,
);
const [cancelling, setCancelling] = useState(false);
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects =
(projectsData?.data as {
id: number;
project_number: string;
name: string;
}[]) ?? [];
const { items, pagination, isPending, isFetching } =
usePaginatedQuery<WarehouseReservation>(
warehouseReservationListOptions({
page,
perPage: PER_PAGE,
status: statusFilter || undefined,
project_id: projectId ? Number(projectId) : undefined,
}),
);
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");
const resetFilters = () => {
setStatusFilter("");
setProjectId("");
setPage(1);
};
const hasActiveFilters = statusFilter || projectId;
const handleCreate = async () => {
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 {
await createMutation.mutateAsync({
item_id: form.item_id,
project_id: form.project_id,
quantity: form.quantity,
notes: form.notes.trim() || null,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleCancel = async () => {
if (!cancelTarget) return;
setCancelling(true);
try {
await cancelMutation.mutateAsync(cancelTarget.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setCancelling(false);
}
};
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? items.length;
const subtitle = `${total} ${czechPlural(total, "rezervace", "rezervace", "rezervací")}`;
const columns: DataColumn<WarehouseReservation>[] = [
{
key: "item",
header: "Položka",
width: "22%",
bold: true,
render: (res) => res.item?.name || `ID: ${res.item_id}`,
},
{
key: "project",
header: "Projekt",
width: "20%",
render: (res) => res.project?.name || "—",
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (res) => String(Number(res.quantity)),
},
{
key: "remaining_qty",
header: "Zbývá",
width: "10%",
mono: true,
render: (res) => String(Number(res.remaining_qty)),
},
{
key: "status",
header: "Stav",
width: "11%",
render: (res) => (
<StatusChip
label={STATUS_LABEL[res.status] ?? res.status}
color={STATUS_COLOR[res.status] ?? "default"}
/>
),
},
{
key: "reserved_by",
header: "Rezervoval",
width: "15%",
render: (res) =>
res.reserved_by_user
? `${res.reserved_by_user.first_name} ${res.reserved_by_user.last_name}`
: "—",
},
{
key: "created_at",
header: "Vytvořeno",
width: "12%",
mono: true,
render: (res) => formatDate(res.created_at),
},
...(canOperate
? [
{
key: "actions",
header: "Akce",
width: "10%",
align: "right" as const,
render: (res: WarehouseReservation) =>
res.status === "ACTIVE" ? (
<Box
sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}
>
<IconButton
size="small"
onClick={() =>
navigate("/warehouse/issues/new", {
state: {
reservationId: res.id,
itemId: res.item_id,
projectId: res.project_id,
quantity: Number(res.remaining_qty),
itemName: res.item?.name,
},
})
}
aria-label="Vytvořit výdej"
title="Vytvořit výdej"
>
{IssueIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setCancelTarget(res)}
aria-label="Zrušit rezervaci"
title="Zrušit rezervaci"
>
{CancelIcon}
</IconButton>
</Box>
) : null,
},
]
: []),
];
return (
<PageEnter>
<PageHeader
title="Rezervace"
subtitle={subtitle}
actions={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => setShowCreateModal(true)}
>
Nová rezervace
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={[
{ value: "", label: "Všechny stavy" },
{ value: "ACTIVE", label: "Aktivní" },
{ value: "FULFILLED", label: "Splněna" },
{ value: "CANCELLED", label: "Zrušena" },
]}
/>
</Box>
{projects.length > 0 && (
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={projectId === "" ? "" : String(projectId)}
onChange={(val) => {
setProjectId(val === "" ? "" : Number(val));
setPage(1);
}}
options={[
{ value: "", label: "Všechny projekty" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
)}
{hasActiveFilters && (
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<WarehouseReservation>
columns={columns}
rows={items}
rowKey={(res) => res.id}
empty={
hasActiveFilters ? (
<EmptyState title="Žádné rezervace pro zadaný filtr." />
) : (
<EmptyState
title="Zatím nejsou žádné rezervace."
action={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => setShowCreateModal(true)}
>
Vytvořit první rezervaci
</Button>
) : undefined
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
{/* Create reservation modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
title="Nová rezervace"
submitText="Vytvořit rezervaci"
loading={saving}
>
<Field label="Položka" required>
<ItemPicker
value={form.item_id}
onChange={(id) => setForm((prev) => ({ ...prev, item_id: id }))}
/>
</Field>
<Field label="Projekt" required>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val ? Number(val) : null,
}))
}
options={[
{ value: "", label: "Vyberte projekt" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Field>
<Field label="Množství" required>
<TextField
type="number"
value={form.quantity || ""}
onChange={(e) => {
const n = Number(e.target.value);
setForm((prev) => ({
...prev,
quantity: Number.isFinite(n) ? n : 0,
}));
}}
inputProps={{ min: 0, step: 0.001 }}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder="Volitelné poznámky"
/>
</Field>
</Modal>
{/* Cancel confirmation modal */}
<ConfirmDialog
isOpen={!!cancelTarget}
onClose={() => setCancelTarget(null)}
onConfirm={handleCancel}
title="Zrušit rezervaci"
message={`Opravdu chcete zrušit rezervaci pro položku „${cancelTarget?.item?.name ?? cancelTarget?.item_id}"?`}
confirmText="Zrušit rezervaci"
confirmVariant="danger"
loading={cancelling}
/>
</PageEnter>
);
}