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>
607 lines
16 KiB
TypeScript
607 lines
16 KiB
TypeScript
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)} Kč`,
|
||
})),
|
||
]}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|