Files
app/src/admin/pages/WarehouseIssueDetail.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

397 lines
10 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 { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseIssueDetailOptions,
type WarehouseIssue,
type WarehouseIssueItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
const STATUS_COLOR: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
DRAFT: "warning",
CONFIRMED: "success",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
DRAFT: "Návrh",
CONFIRMED: "Potvrzeno",
CANCELLED: "Zrušeno",
};
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function WarehouseIssueDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [cancelConfirm, setCancelConfirm] = useState(false);
const {
data: issue,
isPending,
error,
} = useQuery(warehouseIssueDetailOptions(id));
const confirmMutation = useApiMutation<number, void>({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const cancelMutation = useApiMutation<number, void>({
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setCancelConfirm(false);
alert.success("Výdejka 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 handleConfirm = async () => {
if (!issue) return;
try {
await confirmMutation.mutateAsync(issue.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleCancel = async () => {
if (!issue) return;
try {
await cancelMutation.mutateAsync(issue.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
if (isPending) {
return <LoadingState />;
}
if (error || !issue) {
return (
<Box>
<PageHeader
title="Výdejka"
actions={
<Button
component={RouterLink}
to="/warehouse/issues"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
}
/>
<Card>
<EmptyState title="Výdejka nebyla nalezena." />
</Card>
</Box>
);
}
const iss = issue as WarehouseIssue;
const items = iss.items ?? [];
const itemColumns: DataColumn<WarehouseIssueItem>[] = [
{
key: "item",
header: "Položka",
width: "20%",
bold: true,
render: (row) => row.item?.name || `ID: ${row.item_id}`,
},
{
key: "batch",
header: "Šarže",
width: "18%",
mono: true,
render: (row) =>
row.batch
? `${formatDate(row.batch.received_at)} ${formatCurrency(Number(row.batch.unit_price), "CZK")}`
: "—",
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (row) => String(Number(row.quantity)),
},
{
key: "unit_price",
header: "Cena/ks",
width: "13%",
mono: true,
render: (row) =>
row.batch ? formatCurrency(Number(row.batch.unit_price), "CZK") : "—",
},
{
key: "total",
header: "Celkem",
width: "13%",
mono: true,
bold: true,
render: (row) =>
row.batch
? formatCurrency(
Number(row.quantity) * Number(row.batch.unit_price),
"CZK",
)
: "—",
},
{
key: "location",
header: "Lokace",
width: "10%",
mono: true,
render: (row) => row.location?.code || "—",
},
{
key: "reservation",
header: "Rezervace",
width: "10%",
mono: true,
render: (row) =>
row.reservation
? `ID: ${row.reservation.id} (${Number(row.reservation.remaining_qty)} ks)`
: "—",
},
{
key: "notes",
header: "Poznámka",
width: "6%",
render: (row) => row.notes || "—",
},
];
return (
<PageEnter>
{/* Header */}
<PageHeader
title={iss.issue_number || "Nová výdejka"}
subtitle={
iss.project
? `${iss.project.project_number} ${iss.project.name}`
: undefined
}
actions={
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/issues"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[iss.status] ?? iss.status}
color={STATUS_COLOR[iss.status] ?? "default"}
/>
{canOperate && iss.status === "DRAFT" && (
<>
<Button
variant="outlined"
color="inherit"
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
>
Upravit
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
{confirming ? "Potvrzování..." : "Potvrdit"}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
</>
)}
{canOperate && iss.status === "CONFIRMED" && (
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
>
Zrušit
</Button>
)}
</Box>
}
/>
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issue_number || "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
{iss.project ? (
<Typography
variant="body2"
component={RouterLink}
to={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
display: "block",
}}
>
{iss.project.project_number} {iss.project.name}
</Typography>
) : (
<Typography variant="body2"></Typography>
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vydal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(iss.created_at)}
</Typography>
</Box>
{iss.notes && (
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{iss.notes}
</Typography>
</Box>
)}
</Box>
</Card>
{/* Lines table */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<WarehouseIssueItem>
columns={itemColumns}
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
{/* Cancel confirmation dialog */}
<ConfirmDialog
isOpen={cancelConfirm}
onClose={() => setCancelConfirm(false)}
onConfirm={handleCancel}
title="Zrušit výdejku"
message={`Opravdu chcete zrušit výdejku „${iss.issue_number || iss.id}"?`}
confirmText="Zrušit výdejku"
confirmVariant="danger"
loading={cancelling}
/>
</PageEnter>
);
}