diff --git a/src/admin/pages/Warehouse.tsx b/src/admin/pages/Warehouse.tsx new file mode 100644 index 0000000..c32e8d6 --- /dev/null +++ b/src/admin/pages/Warehouse.tsx @@ -0,0 +1,375 @@ +import { useState, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "../context/AuthContext"; +import Forbidden from "../components/Forbidden"; +import { motion } from "framer-motion"; +import { + warehouseStockStatusOptions, + warehouseBelowMinimumOptions, + warehouseReservationListOptions, + type WarehouseItem, + type WarehouseReservation, +} from "../lib/queries/warehouse"; +import apiFetch from "../utils/api"; +import { formatCurrency, formatDate } from "../utils/formatters"; + +interface MovementLogRow { + date: string; + type: string; + document_number: string; + item_name: string; + quantity: number; + unit_price: number; + supplier_or_project: string; +} + +const TYPE_LABEL: Record = { + receipt: "Příjem", + issue: "Výdej", +}; + +export default function Warehouse() { + const { hasPermission } = useAuth(); + + const { data: stockItems, isPending: stockPending } = useQuery( + warehouseStockStatusOptions(), + ); + const { data: belowMinItems, isPending: belowMinPending } = useQuery( + warehouseBelowMinimumOptions(), + ); + const { data: reservationsData, isPending: reservationsPending } = useQuery( + warehouseReservationListOptions({ status: "ACTIVE", perPage: 1 }), + ); + + const [recentMovements, setRecentMovements] = useState([]); + const [movementsLoading, setMovementsLoading] = useState(true); + + useEffect(() => { + async function fetchMovements() { + try { + const response = await apiFetch( + "/api/admin/warehouse/reports/movement-log?limit=10", + ); + const result = await response.json(); + if (result.success) { + setRecentMovements(result.data ?? []); + } + } catch { + // Non-fatal, show empty + } finally { + setMovementsLoading(false); + } + } + fetchMovements(); + }, []); + + if (!hasPermission("warehouse.view")) return ; + + const items = (stockItems as WarehouseItem[] | undefined) ?? []; + const belowMin = (belowMinItems as WarehouseItem[] | undefined) ?? []; + const activeReservationCount = + (reservationsData as { pagination?: { total: number } } | undefined) + ?.pagination?.total ?? 0; + + const totalItems = items.length; + const totalStockValue = items.reduce( + (sum, item) => sum + (item.stock_value ?? 0), + 0, + ); + const belowMinimumCount = belowMin.length; + + const isLoading = + stockPending || belowMinPending || reservationsPending || movementsLoading; + + return ( +
+ +
+

Sklad

+

Přehled skladových zásob

+
+
+ + Položky + + + Reporty + +
+
+ + {/* KPI cards */} +
+ +
+
+ Celkem položek +
+
+ {isLoading ? "—" : totalItems} +
+
+
+ + +
+
+ Hodnota zásob +
+
+ {isLoading ? "—" : formatCurrency(totalStockValue, "CZK")} +
+
+
+ + 0 + ? { border: "2px solid var(--danger)" } + : undefined + } + > +
+
0 + ? "var(--danger)" + : "var(--text-secondary)", + marginBottom: "0.25rem", + }} + > + Pod minimem +
+
0 + ? "var(--danger)" + : "var(--text-primary)", + }} + > + {isLoading ? "—" : belowMinimumCount} +
+
+
+ + +
+
+ Aktivní rezervace +
+
+ {isLoading ? "—" : activeReservationCount} +
+
+
+
+ + {/* Below minimum alert */} + {belowMin.length > 0 && ( + +
+

+ Položky pod minimem +

+ + Reporty → + +
+
+
+ + + + + + + + + + {belowMin.map((item) => ( + + + + + + ))} + +
NázevK dispoziciMin. množství
{item.name} + {item.available_quantity ?? 0} + + {item.min_quantity ?? 0} +
+
+
+
+ )} + + {/* Recent movements */} + +
+

Poslední pohyby

+ + Vše → + +
+
+ {movementsLoading ? ( +
+
+
+ ) : recentMovements.length === 0 ? ( +
+ Žádné nedávné pohyby +
+ ) : ( +
+ + + + + + + + + + + + + {recentMovements.map((row, i) => ( + + + + + + + + + ))} + +
DatumTypDokladPoložkaMnožstvíDodavatel / Projekt
{row.date} + + {TYPE_LABEL[row.type] ?? row.type} + + + {row.document_number || "—"} + {row.item_name}{row.quantity}{row.supplier_or_project || "—"}
+
+ )} +
+ +
+ ); +} diff --git a/src/admin/pages/WarehouseInventory.tsx b/src/admin/pages/WarehouseInventory.tsx new file mode 100644 index 0000000..83a6532 --- /dev/null +++ b/src/admin/pages/WarehouseInventory.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import Forbidden from "../components/Forbidden"; +import { motion, AnimatePresence } from "framer-motion"; +import Pagination from "../components/Pagination"; +import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; + +import { formatDate } from "../utils/formatters"; +import { + warehouseInventoryListOptions, + type WarehouseInventorySession, +} from "../lib/queries/warehouse"; + +const PER_PAGE = 20; + +const STATUS_BADGE: Record = { + DRAFT: "admin-badge-warning", + CONFIRMED: "admin-badge-active", +}; + +const STATUS_LABEL: Record = { + DRAFT: "Návrh", + CONFIRMED: "Potvrzeno", +}; + +export default function WarehouseInventory() { + const { hasPermission } = useAuth(); + + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + + const { items, pagination, isPending, isFetching } = + usePaginatedQuery( + warehouseInventoryListOptions({ + page, + perPage: PER_PAGE, + status: statusFilter || undefined, + }), + ); + + const navigate = useNavigate(); + + if (!hasPermission("warehouse.inventory")) return ; + + const handleRowClick = (session: WarehouseInventorySession) => { + navigate(`/warehouse/inventory/${session.id}`); + }; + + const resetFilters = () => { + setStatusFilter(""); + setPage(1); + }; + + if (isPending) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+

Inventury

+

+ {pagination?.total ?? items.length}{" "} + {pagination?.total === 1 + ? "inventura" + : pagination?.total !== undefined && + pagination.total >= 2 && + pagination.total <= 4 + ? "inventury" + : "inventur"} +

+
+
+ +
+
+ + +
+
+ + {statusFilter && ( + + )} +
+ + + + {items.length === 0 && ( +
+
+ + + + + + +
+

+ {statusFilter + ? "Žádné inventury pro zadaný filtr." + : "Zatím nejsou žádné inventury."} +

+ {!statusFilter && ( + + )} +
+ )} + {items.length > 0 && ( +
+ + + + + + + + + + + {items.map((session) => ( + handleRowClick(session)} + style={{ cursor: "pointer" }} + > + + + + + + ))} + +
Číslo inventuryStavVytvořenoŘádků
+ {session.session_number || "—"} + + + {STATUS_LABEL[session.status] ?? session.status} + + + {formatDate(session.created_at)} + + {session.lines?.length ?? 0} +
+
+ )} +
+
+ + +
+
+
+ ); +} diff --git a/src/admin/pages/WarehouseInventoryDetail.tsx b/src/admin/pages/WarehouseInventoryDetail.tsx new file mode 100644 index 0000000..ad8c725 --- /dev/null +++ b/src/admin/pages/WarehouseInventoryDetail.tsx @@ -0,0 +1,280 @@ +import { useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAlert } from "../context/AlertContext"; +import { useAuth } from "../context/AuthContext"; +import Forbidden from "../components/Forbidden"; +import { motion } from "framer-motion"; +import ConfirmModal from "../components/ConfirmModal"; +import FormField from "../components/FormField"; + +import apiFetch from "../utils/api"; +import { formatDate } from "../utils/formatters"; +import { + warehouseInventoryDetailOptions, + type WarehouseInventorySession, +} from "../lib/queries/warehouse"; + +const STATUS_BADGE: Record = { + DRAFT: "admin-badge-warning", + CONFIRMED: "admin-badge-active", +}; + +const STATUS_LABEL: Record = { + DRAFT: "Návrh", + CONFIRMED: "Potvrzeno", +}; + +export default function WarehouseInventoryDetail() { + const { id } = useParams(); + const alert = useAlert(); + const { hasPermission } = useAuth(); + const queryClient = useQueryClient(); + + const [confirming, setConfirming] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); + + const { + data: session, + isPending, + error, + } = useQuery(warehouseInventoryDetailOptions(id)); + + if (!hasPermission("warehouse.inventory")) return ; + + const handleConfirm = async () => { + if (!session) return; + setConfirming(true); + try { + const response = await apiFetch( + `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`, + { method: "POST" }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "inventory"] }); + setShowConfirmModal(false); + alert.success("Inventura byla potvrzena"); + } else { + alert.error(result.error || "Nepodařilo se potvrdit inventuru"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setConfirming(false); + } + }; + + if (isPending) { + return ( +
+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
+ + + + + +
+
+
+
+
+

Inventura nebyla nalezena.

+
+
+
+
+ ); + } + + const s = session as WarehouseInventorySession; + const lines = s.lines ?? []; + + return ( +
+ {/* Header */} + +
+ + + + + +
+

+ {s.session_number || "Inventura"} +

+
+ + {STATUS_LABEL[s.status] ?? s.status} + +
+
+ {s.status === "DRAFT" && ( + + )} +
+
+ + {/* Header info */} + +
+

Základní údaje

+
+
+ +
+ {s.session_number || "—"} +
+
+ +
{formatDate(s.created_at)}
+
+
+ {s.notes && ( + +
{s.notes}
+
+ )} +
+
+
+ + {/* Lines table */} + +
+

Řádky inventury

+ {lines.length === 0 ? ( +
+ Žádné řádky +
+ ) : ( +
+ + + + + + + + + + + + + {lines.map((line) => { + const diff = Number(line.difference); + const diffColor = + diff > 0 + ? "var(--success)" + : diff < 0 + ? "var(--danger)" + : "var(--text-secondary)"; + return ( + + + + + + + + + ); + })} + +
PoložkaLokaceSystémové množstvíSkutečné množstvíRozdílPoznámka
+ {line.item?.name || `ID: ${line.item_id}`} + + {line.location?.code || "—"} + + {Number(line.system_qty)} + + {Number(line.actual_qty)} + + {diff > 0 ? `+${diff}` : diff} + {line.notes || "—"}
+
+ )} +
+
+ + {/* Confirm modal */} + setShowConfirmModal(false)} + onConfirm={handleConfirm} + title="Potvrdit inventuru" + message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`} + confirmText="Potvrdit inventuru" + type="warning" + loading={confirming} + /> +
+ ); +} diff --git a/src/admin/pages/WarehouseInventoryForm.tsx b/src/admin/pages/WarehouseInventoryForm.tsx new file mode 100644 index 0000000..463ae2a --- /dev/null +++ b/src/admin/pages/WarehouseInventoryForm.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +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 ItemPicker from "../components/warehouse/ItemPicker"; +import LocationSelect from "../components/warehouse/LocationSelect"; +import { warehouseLocationListOptions } from "../lib/queries/warehouse"; +import type { WarehouseLocation } from "../lib/queries/warehouse"; + +import apiFetch from "../utils/api"; + +interface InventoryLine { + key: string; + item_id: number | null; + location_id: number | null; + actual_qty: number; +} + +let lineCounter = 0; + +export default function WarehouseInventoryForm() { + const alert = useAlert(); + const { hasPermission } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [lines, setLines] = useState([]); + const [notes, setNotes] = useState(""); + const [saving, setSaving] = useState(false); + + const { data: locations = [] } = useQuery(warehouseLocationListOptions()); + + if (!hasPermission("warehouse.inventory")) return ; + + const addEmptyLine = () => { + setLines((prev) => [ + ...prev, + { + key: `line-${++lineCounter}`, + item_id: null, + location_id: null, + actual_qty: 0, + }, + ]); + }; + + // Start with one empty line + if (lines.length === 0) { + addEmptyLine(); + } + + const removeLine = (index: number) => { + setLines((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateLine = (index: number, field: string, value: unknown) => { + setLines((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], [field]: value }; + return updated; + }); + }; + + const validate = (): boolean => { + const validLines = lines.filter((l) => l.item_id !== null); + if (validLines.length === 0) { + alert.error("Přidejte alespoň jednu položku"); + return false; + } + return true; + }; + + const handleSubmit = async () => { + if (!validate()) return; + setSaving(true); + try { + const payload = { + notes: notes.trim() || null, + lines: lines + .filter((l) => l.item_id !== null) + .map((l) => ({ + item_id: l.item_id!, + location_id: l.location_id, + actual_qty: l.actual_qty, + })), + }; + + const response = await apiFetch( + "/api/admin/warehouse/inventory-sessions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + const result = await response.json(); + if (result.success) { + queryClient.invalidateQueries({ queryKey: ["warehouse", "inventory"] }); + alert.success("Inventura byla vytvořena"); + navigate(`/warehouse/inventory/${result.data?.id}`); + } else { + alert.error(result.error || "Nepodařilo se vytvořit inventuru"); + } + } catch { + alert.error("Chyba připojení"); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Header */} + +
+ + + + + +
+

Nová inventura

+
+
+
+ +
+
+ + {/* Notes */} + +
+

Základní údaje

+ +