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>
501 lines
13 KiB
TypeScript
501 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|