Files
app/src/admin/pages/WarehouseIssueForm.tsx
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

607 lines
16 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, useEffect, useRef, useId, useCallback } from "react";
import {
useParams,
useNavigate,
useLocation,
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 IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import ItemPicker from "../components/warehouse/ItemPicker";
import { jsonQuery } from "../lib/apiAdapter";
import {
warehouseIssueDetailOptions,
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
TextField,
Select,
Field,
LoadingState,
PageEnter,
} from "../ui";
interface IssueForm {
project_id: number | null;
notes: string;
}
interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: 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"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const PlusIcon = (
<svg
width="16"
height="16"
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>
);
/** Inline batch dropdown: lists FIFO batches for an item, sets batch + unit price. */
function BatchSelect({
itemId,
value,
onChange,
}: {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<Box>
{response && response.reserved_quantity > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</Typography>
)}
<Select
value={value === null ? "" : String(value)}
onChange={(val) => {
const batchId = Number(val);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
options={[
{ value: "", label: "-- auto FIFO --" },
...batches.map((b) => ({
value: String(b.id),
label: `${new Date(b.received_at).toLocaleDateString("cs-CZ")} | ${b.quantity} ks | ${Number(b.unit_price).toFixed(2)}`,
})),
]}
/>
</Box>
);
}
export default function WarehouseIssueForm() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const baseId = useId();
const counterRef = useRef(0);
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
reservationId?: number;
itemId?: number;
projectId?: number;
quantity?: number;
itemName?: string;
} | null;
const isEdit = id !== undefined && id !== "new";
const [form, setForm] = useState<IssueForm>({
project_id: prefill?.projectId ?? null,
notes: "",
});
const [items, setItems] = useState<MovementItem[]>(() =>
isEdit
? []
: prefill?.itemId
? [
{
key: nextKey(),
item_id: prefill.itemId,
item_name: prefill.itemName,
quantity: prefill.quantity ?? 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: prefill.reservationId ?? null,
notes: null,
},
]
: [
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const formInitialized = useRef(false);
const { data: issue, isPending } = useQuery(
warehouseIssueDetailOptions(isEdit ? id : undefined),
);
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
const { data: locations } = useQuery(warehouseLocationListOptions());
useEffect(() => {
formInitialized.current = false;
}, [id]);
useEffect(() => {
if (isEdit && issue && !formInitialized.current) {
setForm({
project_id: issue.project_id,
notes: issue.notes || "",
});
if (issue.items && issue.items.length > 0) {
setItems(
issue.items.map((item) => ({
key: nextKey(),
item_id: item.item_id,
item_name: item.item?.name,
quantity: Number(item.quantity),
unit_price: item.batch ? Number(item.batch.unit_price) : 0,
location_id: item.location_id,
batch_id: item.batch_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
);
}
formInitialized.current = true;
}
}, [isEdit, issue, nextKey]);
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 = () => {
setItems((prev) => [
...prev,
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
]);
};
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof MovementItem,
value: unknown,
) => {
setItems((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.project_id) {
newErrors.project_id = "Projekt je povinný";
}
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
newErrors.items = "Přidejte alespoň jednu položku";
}
for (const item of validItems) {
if (item.quantity <= 0) {
newErrors.items = "Množství musí být větší než 0";
break;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const buildPayload = () => ({
project_id: form.project_id,
notes: form.notes.trim() || null,
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
batch_id: l.batch_id,
quantity: l.quantity,
location_id: l.location_id,
reservation_id: l.reservation_id,
notes: l.notes?.trim() || null,
})),
});
const saveIssue = async (): Promise<number | null> => {
try {
const data = await saveMutation.mutateAsync(buildPayload());
return data?.id ?? Number(id);
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit výdejku",
);
return null;
}
};
const confirmIssue = async (issueId: number) => {
try {
await confirmMutation.mutateAsync(issueId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleSaveDraft = async () => {
if (!validate()) return;
setSaving(true);
try {
const savedId = await saveIssue();
if (savedId) {
alert.success("Výdejka byla uložena jako návrh");
navigate(`/warehouse/issues/${savedId}`);
}
} finally {
setSaving(false);
}
};
const handleSaveAndConfirm = async () => {
if (!validate()) return;
setSaving(true);
try {
const savedId = await saveIssue();
if (savedId) {
await confirmIssue(savedId);
navigate(`/warehouse/issues/${savedId}`);
}
} finally {
setSaving(false);
}
};
if (isEdit && isPending) {
return <LoadingState />;
}
const projectOptions = [
{ value: "", label: "Vyberte projekt..." },
...projects.map((p: Project) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
];
const locationOptions = [
{ value: "", label: "-- bez lokace --" },
...(locations ?? []).map((loc: WarehouseLocation) => ({
value: String(loc.id),
label: `${loc.code} - ${loc.name}`,
})),
];
return (
<PageEnter>
{/* Header */}
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/issues"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveDraft}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</Button>
<Button onClick={handleSaveAndConfirm} disabled={saving}>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</Button>
</Box>
</Box>
{/* Header fields */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Projekt" error={errors.project_id}>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id: val === "" ? null : Number(val),
}))
}
options={projectOptions}
/>
</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>
</Card>
{/* Lines */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</Typography>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
<BatchSelect
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
disabled
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
</PageEnter>
);
}