From 1b2ab7b4eba0239dba2ac400dde9377f4c1baaf8 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 29 May 2026 14:27:15 +0200 Subject: [PATCH] feat(warehouse): add items list and detail pages Co-Authored-By: Claude Opus 4.8 --- src/admin/AdminApp.tsx | 4 + src/admin/pages/WarehouseItemDetail.tsx | 671 ++++++++++++++++++++++++ src/admin/pages/WarehouseItems.tsx | 319 +++++++++++ 3 files changed, 994 insertions(+) create mode 100644 src/admin/pages/WarehouseItemDetail.tsx create mode 100644 src/admin/pages/WarehouseItems.tsx diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index b6d8b6e..9872c68 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -153,6 +153,10 @@ export default function AdminApp() { } /> } /> } /> + } + /> } diff --git a/src/admin/pages/WarehouseItemDetail.tsx b/src/admin/pages/WarehouseItemDetail.tsx new file mode 100644 index 0000000..38e283b --- /dev/null +++ b/src/admin/pages/WarehouseItemDetail.tsx @@ -0,0 +1,671 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams, useNavigate, Link } from "react-router-dom"; +import { useAlert } from "../context/AlertContext"; +import { useAuth } from "../context/AuthContext"; +import Forbidden from "../components/Forbidden"; +import { motion } from "framer-motion"; +import FormField from "../components/FormField"; +import useModalLock from "../hooks/useModalLock"; + +import apiFetch from "../utils/api"; +import { formatCurrency, formatDate } from "../utils/formatters"; +import { + warehouseItemDetailOptions, + warehouseCategoryListOptions, + type WarehouseItem, +} from "../lib/queries/warehouse"; + +interface Batch { + id: number; + quantity: number; + original_qty: number; + unit_price: number; + received_at: string | null; + is_consumed: boolean; +} + +interface ItemLocation { + id: number; + quantity: number; + location: { + id: number; + code: string; + name: string; + } | null; +} + +interface ItemDetail extends WarehouseItem { + batches?: Batch[]; + item_locations?: ItemLocation[]; +} + +const API_BASE = "/api/admin/warehouse/items"; + +interface ItemForm { + item_number: string; + name: string; + description: string; + category_id: string; + unit: string; + min_quantity: string; + notes: string; + is_active: boolean; +} + +export default function WarehouseItemDetail() { + const { id } = useParams(); + const alert = useAlert(); + const { hasPermission } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState({ + item_number: "", + name: "", + description: "", + category_id: "", + unit: "ks", + min_quantity: "", + notes: "", + is_active: true, + }); + const [errors, setErrors] = useState>({}); + + const itemQuery = useQuery(warehouseItemDetailOptions(id)); + const item = itemQuery.data as ItemDetail | undefined; + const isPending = itemQuery.isPending; + + const { data: categories = [] } = useQuery(warehouseCategoryListOptions()); + + const formInitialized = useRef(false); + useEffect(() => { + formInitialized.current = false; + }, [id]); + + useEffect(() => { + if (item && !formInitialized.current) { + setForm({ + item_number: item.item_number || "", + name: item.name, + description: item.description || "", + category_id: item.category_id ? String(item.category_id) : "", + unit: item.unit, + min_quantity: + item.min_quantity != null ? String(item.min_quantity) : "", + notes: item.notes || "", + is_active: item.is_active, + }); + formInitialized.current = true; + } + }, [item]); + + useEffect(() => { + if (itemQuery.error) { + alert.error("Položka nenalezena"); + navigate("/warehouse/items"); + } + }, [itemQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps + + useModalLock(editing); + + if (!hasPermission("warehouse.view")) return ; + + const canManage = hasPermission("warehouse.manage"); + + const updateForm = (field: keyof ItemForm, value: string | boolean) => + setForm((prev) => ({ ...prev, [field]: value })); + + const handleSave = async () => { + const newErrors: Record = {}; + if (!form.name.trim()) newErrors.name = "Zadejte název položky"; + if (!form.unit.trim()) newErrors.unit = "Zadejte jednotku"; + setErrors(newErrors); + if (Object.keys(newErrors).length > 0) return; + + setSaving(true); + try { + const isNew = id === "new"; + const url = isNew ? API_BASE : `${API_BASE}/${id}`; + const method = isNew ? "POST" : "PUT"; + + const payload: Record = { + item_number: form.item_number.trim() || null, + name: form.name.trim(), + description: form.description.trim() || null, + category_id: form.category_id ? Number(form.category_id) : null, + unit: form.unit.trim(), + min_quantity: form.min_quantity ? Number(form.min_quantity) : null, + notes: form.notes.trim() || null, + is_active: form.is_active, + }; + + const response = await apiFetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const result = await response.json(); + + if (result.success) { + setEditing(false); + queryClient.invalidateQueries({ queryKey: ["warehouse"] }); + alert.success(result.message || "Položka byla uložena"); + + if (isNew && result.data?.id) { + navigate(`/warehouse/items/${result.data.id}`, { replace: true }); + } + } else { + alert.error(result.error || "Nepodařilo se uložit položku"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!item) return; + try { + const response = await apiFetch(`${API_BASE}/${id}`, { + method: "DELETE", + }); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse"] }); + navigate("/warehouse/items"); + setTimeout(() => alert.success("Položka byla smazána"), 300); + } else { + alert.error(result.error || "Nepodařilo se smazat položku"); + } + } catch { + alert.error("Chyba připojení"); + } + }; + + const handleToggleActive = async () => { + if (!item) return; + try { + const response = await apiFetch(`${API_BASE}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_active: !item.is_active }), + }); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse"] }); + alert.success( + item.is_active + ? "Položka byla deaktivována" + : "Položka byla aktivována", + ); + } else { + alert.error(result.error || "Nepodařilo se změnit stav"); + } + } catch { + alert.error("Chyba připojení"); + } + }; + + if (isPending && id !== "new") { + return ( +
+
+
+ ); + } + + const batches: Batch[] = item?.batches ?? []; + const locations: ItemLocation[] = item?.item_locations ?? []; + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {id === "new" + ? "Nová položka" + : item + ? item.name + : "Detail položky"} +

+ {item?.item_number && ( +

{item.item_number}

+ )} +
+
+ {canManage && ( +
+ {editing ? ( + <> + + + + ) : ( + <> + {id !== "new" && item && ( + <> + + + + )} + + + )} +
+ )} +
+ + {/* Basic info */} + +
+

Základní údaje

+
+ {editing || id === "new" ? ( + <> +
+ + { + updateForm("item_number", e.target.value); + setErrors((prev) => ({ ...prev, item_number: "" })); + }} + className="admin-form-input" + placeholder="Např. SKL-001" + /> + + + { + updateForm("name", e.target.value); + setErrors((prev) => ({ ...prev, name: "" })); + }} + className="admin-form-input" + placeholder="Název položky" + aria-invalid={!!errors.name} + /> + +
+ +