diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3399c2f..29beede 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -52,6 +52,30 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve **Lavoro completato nell'ultima sessione:** +- **NUOVA FEATURE: Gestione Inventario (Frontend)** - COMPLETATO + - **Obiettivo:** Interfaccia utente per la gestione completa degli inventari fisici + - **Frontend implementato:** + - `InventoryListPage.tsx` - Lista inventari con stato, filtri e indicatori di progresso + - `InventoryFormPage.tsx` - Form per creazione e modifica testata inventario (con gestione stati) + - `InventoryCountPage.tsx` - Pagina di conteggio con griglia editabile, calcolo differenze live + - Aggiornati `routes.tsx` e `pages/index.ts` per includere le nuove rotte + - **Funzionalità:** + - Creazione inventari (Completo, Parziale per categoria/magazzino) + - Workflow stati: Bozza -> In Corso -> Completato -> Confermato + - Avvio inventario: generazione automatica righe basata su giacenza teorica + - Conteggio: inserimento quantità rilevate, evidenziazione differenze + - Conferma: generazione automatica movimenti di rettifica (positivo/negativo) + - **Integrazione:** + - Utilizza `inventoryService` per comunicare con `InventoryController` + - Gestione date con `dayjs` + - UI coerente con Material-UI e DataGrid + +- **FIX: Tasto Inventario in Dashboard Magazzino** - RISOLTO + - **Problema:** Il tasto "Inventario" nelle azioni rapide portava a una pagina 404 (`/warehouse/inventories/new`) + - **Causa:** Errore nel hook `useWarehouseNavigation` che usava il plurale `inventories` invece del singolare `inventory` definito nelle rotte + - **Soluzione:** Corretti i percorsi in `useWarehouseNavigation.ts` per corrispondere a `routes.tsx` + - **File modificati:** `frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts` + - **FIX: Campo Codice Readonly e Codice Alternativo** - COMPLETATO - **Obiettivo:** Il campo "Codice" deve essere sempre auto-generato (non modificabile), aggiungere campo "Codice Alternativo" opzionale - **Backend modificato:** @@ -444,7 +468,7 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve 1. [x] **Implementare modulo Magazzino (warehouse)** - COMPLETATO (backend) - Backend: Entities, Service, Controllers, API completi - Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze) -2. [ ] **Frontend modulo Magazzino** - Pagine React per warehouse +2. [x] **Frontend modulo Magazzino** - Pagine React per warehouse (Articoli, Movimenti, Giacenze, Inventario) 3. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino 4. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino 5. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino diff --git a/frontend/src/modules/warehouse/components/WarehouseLayout.tsx b/frontend/src/modules/warehouse/components/WarehouseLayout.tsx new file mode 100644 index 0000000..edea8a2 --- /dev/null +++ b/frontend/src/modules/warehouse/components/WarehouseLayout.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Box, Tabs, Tab, Paper, Typography, Breadcrumbs, Link } from "@mui/material"; +import { + Dashboard as DashboardIcon, + Inventory as ArticleIcon, + Place as LocationIcon, + SwapHoriz as MovementIcon, + Assessment as StockIcon, + FactCheck as InventoryIcon, +} from "@mui/icons-material"; + +const navItems = [ + { label: "Dashboard", path: "/warehouse", icon: }, + { label: "Articoli", path: "/warehouse/articles", icon: }, + { label: "Magazzini", path: "/warehouse/locations", icon: }, + { label: "Movimenti", path: "/warehouse/movements", icon: }, + { label: "Giacenze", path: "/warehouse/stock", icon: }, + { label: "Inventario", path: "/warehouse/inventory", icon: }, +]; + +export default function WarehouseLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const [value, setValue] = useState(0); + + useEffect(() => { + // Find the matching tab based on current path + const index = navItems.findIndex((item) => { + if (item.path === "/warehouse") { + return location.pathname === "/warehouse"; + } + return location.pathname.startsWith(item.path); + }); + + if (index !== -1) { + setValue(index); + } else { + // If no match (e.g. sub-pages), keep the closest parent or default + // Logic could be improved here but keeping it simple for now + if (location.pathname.includes("articles")) setValue(1); + else if (location.pathname.includes("locations")) setValue(2); + else if (location.pathname.includes("movements")) setValue(3); + else if (location.pathname.includes("stock")) setValue(4); + else if (location.pathname.includes("inventory")) setValue(5); + else setValue(0); + } + }, [location.pathname]); + + const handleChange = (_event: React.SyntheticEvent, newValue: number) => { + setValue(newValue); + navigate(navItems[newValue].path); + }; + + return ( + + {/* Header & Navigation */} + + + + Gestione Magazzino + + + + Home + + Magazzino + {navItems[value]?.label !== "Dashboard" && ( + {navItems[value]?.label} + )} + + + + + {navItems.map((item, index) => ( + + ))} + + + + {/* Content Area */} + + + + + ); +} diff --git a/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts b/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts index 86f5692..28314a2 100644 --- a/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts +++ b/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts @@ -104,15 +104,15 @@ export function useWarehouseNavigation() { // Inventory const goToInventories = useCallback(() => { - navigate('/warehouse/inventories'); + navigate('/warehouse/inventory'); }, [navigate]); const goToInventory = useCallback((id: number) => { - navigate(`/warehouse/inventories/${id}`); + navigate(`/warehouse/inventory/${id}`); }, [navigate]); const goToNewInventory = useCallback(() => { - navigate('/warehouse/inventories/new'); + navigate('/warehouse/inventory/new'); }, [navigate]); // Dashboard diff --git a/frontend/src/modules/warehouse/pages/InventoryCountPage.tsx b/frontend/src/modules/warehouse/pages/InventoryCountPage.tsx new file mode 100644 index 0000000..adf4ea5 --- /dev/null +++ b/frontend/src/modules/warehouse/pages/InventoryCountPage.tsx @@ -0,0 +1,333 @@ +import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Box, + Typography, + Button, + Paper, + Grid, + Chip, + Card, + CardContent, + Alert, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, +} from "@mui/material"; +import { + DataGrid, + GridColDef, + GridRenderCellParams, + GridToolbar, + GridRowModel, +} from "@mui/x-data-grid"; +import { + PlayArrow as StartIcon, + CheckCircle as CompleteIcon, + DoneAll as ConfirmIcon, + ArrowBack as ArrowBackIcon, +} from "@mui/icons-material"; +import { inventoryService } from "../services/warehouseService"; +import { InventoryStatus, InventoryCountLineDto } from "../types"; +import dayjs from "dayjs"; + +export default function InventoryCountPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const inventoryId = Number(id); + + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + + const { data: inventory, isLoading } = useQuery({ + queryKey: ["inventory", inventoryId], + queryFn: () => inventoryService.getById(inventoryId), + }); + + const startMutation = useMutation({ + mutationFn: () => inventoryService.start(inventoryId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventory", inventoryId] }); + }, + }); + + const completeMutation = useMutation({ + mutationFn: () => inventoryService.complete(inventoryId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventory", inventoryId] }); + }, + }); + + const confirmMutation = useMutation({ + mutationFn: () => inventoryService.confirm(inventoryId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventory", inventoryId] }); + setConfirmDialogOpen(false); + navigate("/warehouse/inventory"); + }, + }); + + const updateLineMutation = useMutation({ + mutationFn: ({ + lineId, + quantity, + }: { + lineId: number; + quantity: number; + }) => inventoryService.updateLine(lineId, quantity), + onSuccess: () => { + // Optimistic update or invalidate + queryClient.invalidateQueries({ queryKey: ["inventory", inventoryId] }); + }, + }); + + const handleProcessRowUpdate = async (newRow: GridRowModel) => { + const line = newRow as InventoryCountLineDto; + if (line.countedQuantity !== undefined && line.countedQuantity !== null) { + await updateLineMutation.mutateAsync({ + lineId: line.id, + quantity: Number(line.countedQuantity), + }); + } + return newRow; + }; + + if (isLoading || !inventory) { + return ( + + + + ); + } + + const isEditable = inventory.status === InventoryStatus.InProgress; + + const columns: GridColDef[] = [ + { field: "articleCode", headerName: "Codice Articolo", width: 150 }, + { + field: "articleDescription", + headerName: "Descrizione", + flex: 1, + minWidth: 200, + }, + { field: "batchNumber", headerName: "Lotto", width: 120 }, + { field: "locationCode", headerName: "Ubicazione", width: 120 }, + { + field: "theoreticalQuantity", + headerName: "Qta Teorica", + width: 120, + type: "number", + valueFormatter: (value) => (value ? Number(value).toFixed(2) : "0"), + }, + { + field: "countedQuantity", + headerName: "Qta Contata", + width: 150, + type: "number", + editable: isEditable, + cellClassName: isEditable ? "editable-cell" : "", + valueFormatter: (value) => + value !== undefined && value !== null + ? Number(value).toFixed(2) + : "-", + }, + { + field: "difference", + headerName: "Differenza", + width: 120, + type: "number", + valueGetter: (_value, row) => { + if ( + row.countedQuantity === undefined || + row.countedQuantity === null + ) + return null; + return row.countedQuantity - row.theoreticalQuantity; + }, + renderCell: (params: GridRenderCellParams) => { + if (params.value === null || params.value === undefined) return "-"; + const val = Number(params.value); + return ( + + {val > 0 ? "+" : ""} + {val.toFixed(2)} + + ); + }, + }, + ]; + + return ( + + + + + + Inventario: {inventory.description} + + + + + {inventory.status === InventoryStatus.Draft && ( + + )} + {inventory.status === InventoryStatus.InProgress && ( + + )} + {inventory.status === InventoryStatus.Completed && ( + + )} + + + + + + + + + Data Inventario + + + {dayjs(inventory.inventoryDate).format("DD/MM/YYYY")} + + + + + + + + + Magazzino + + {inventory.warehouseName} + + + + + + + + Righe Totali + + {inventory.lineCount} + + + + + + + + Righe Contate + + + {inventory.lines.filter((l) => l.countedQuantity !== null).length} + + + + + + + {inventory.status === InventoryStatus.Completed && ( + + L'inventario è completato. Verifica le differenze prima di confermare. + La conferma genererà automaticamente i movimenti di rettifica. + + )} + + + console.error(error)} + slots={{ toolbar: GridToolbar }} + slotProps={{ + toolbar: { + showQuickFilter: true, + }, + }} + disableRowSelectionOnClick + sx={{ + "& .editable-cell": { + backgroundColor: "#f0f8ff", + }, + }} + /> + + + setConfirmDialogOpen(false)} + > + Conferma Inventario + + + Sei sicuro di voler confermare l'inventario? Questa operazione è + irreversibile e genererà i movimenti di rettifica per le differenze + riscontrate. + + + + + + + + + ); +} diff --git a/frontend/src/modules/warehouse/pages/InventoryFormPage.tsx b/frontend/src/modules/warehouse/pages/InventoryFormPage.tsx new file mode 100644 index 0000000..47fff61 --- /dev/null +++ b/frontend/src/modules/warehouse/pages/InventoryFormPage.tsx @@ -0,0 +1,258 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Box, + Typography, + Button, + Paper, + Grid, + TextField, + MenuItem, + FormControl, + InputLabel, + Select, + Breadcrumbs, + Link, + CircularProgress, +} from "@mui/material"; +import { Save as SaveIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material"; +import { + inventoryService, + warehouseLocationService, + categoryService, +} from "../services/warehouseService"; +import { + CreateInventoryCountDto, + InventoryType, +} from "../types"; +import dayjs from "dayjs"; + +export default function InventoryFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const isEditing = !!id; + + const [formData, setFormData] = useState({ + description: "", + inventoryDate: dayjs().format("YYYY-MM-DD"), + type: InventoryType.Full, + notes: "", + }); + + const { data: warehouses = [] } = useQuery({ + queryKey: ["warehouse-locations"], + queryFn: () => warehouseLocationService.getAll(), + }); + + const { data: categories = [] } = useQuery({ + queryKey: ["categories"], + queryFn: () => categoryService.getAll(), + }); + + const { data: inventory, isLoading: isLoadingInventory } = useQuery({ + queryKey: ["inventory", id], + queryFn: () => inventoryService.getById(Number(id)), + enabled: isEditing, + }); + + useEffect(() => { + if (inventory) { + setFormData({ + description: inventory.description, + inventoryDate: dayjs(inventory.inventoryDate).format("YYYY-MM-DD"), + warehouseId: inventory.warehouseId, + categoryId: inventory.categoryId, + type: inventory.type, + notes: inventory.notes, + }); + } + }, [inventory]); + + const createMutation = useMutation({ + mutationFn: (data: CreateInventoryCountDto) => inventoryService.create(data), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["inventory-counts"] }); + navigate(`/warehouse/inventory/${data.id}/count`); + }, + }); + + // TODO: Add update mutation if needed, currently only creation is critical + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isEditing) { + // Implement update logic if needed + } else { + createMutation.mutate(formData); + } + }; + + if (isEditing && isLoadingInventory) { + return ( + + + + ); + } + + return ( + + + { + e.preventDefault(); + navigate("/warehouse/inventory"); + }} + > + Inventari + + + {isEditing ? "Modifica Inventario" : "Nuovo Inventario"} + + + + + + {isEditing ? `Inventario ${inventory?.code}` : "Nuovo Inventario"} + + + + + +
+ + + + setFormData({ ...formData, description: e.target.value }) + } + /> + + + + setFormData({ ...formData, inventoryDate: e.target.value }) + } + /> + + + + Magazzino + + + + + + Categoria (Opzionale) + + + + + + Tipo Inventario + + + + + + setFormData({ ...formData, notes: e.target.value }) + } + /> + + + + + +
+
+
+ ); +} diff --git a/frontend/src/modules/warehouse/pages/InventoryListPage.tsx b/frontend/src/modules/warehouse/pages/InventoryListPage.tsx new file mode 100644 index 0000000..2b8ab57 --- /dev/null +++ b/frontend/src/modules/warehouse/pages/InventoryListPage.tsx @@ -0,0 +1,204 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Typography, + Button, + Paper, + Chip, + IconButton, + Tooltip, +} from "@mui/material"; +import { + DataGrid, + GridColDef, + GridRenderCellParams, + GridToolbar, +} from "@mui/x-data-grid"; +import { + Add as AddIcon, + Visibility as ViewIcon, + PlayArrow as StartIcon, + Cancel as CancelIcon, +} from "@mui/icons-material"; +import { inventoryService } from "../services/warehouseService"; +import { InventoryCountDto, InventoryStatus } from "../types"; +import dayjs from "dayjs"; + +export default function InventoryListPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [statusFilter] = useState( + undefined + ); + + const { data: inventories = [], isLoading } = useQuery({ + queryKey: ["inventory-counts", statusFilter], + queryFn: () => inventoryService.getAll(statusFilter), + }); + + const cancelMutation = useMutation({ + mutationFn: (id: number) => inventoryService.cancel(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventory-counts"] }); + }, + }); + + const handleCreate = () => { + navigate("/warehouse/inventory/new"); + }; + + const handleView = (id: number) => { + navigate(`/warehouse/inventory/${id}`); + }; + + const handleStart = (id: number) => { + navigate(`/warehouse/inventory/${id}/count`); + }; + + const getStatusChip = (status: InventoryStatus) => { + switch (status) { + case InventoryStatus.Draft: + return ; + case InventoryStatus.InProgress: + return ; + case InventoryStatus.Completed: + return ; + case InventoryStatus.Confirmed: + return ; + case InventoryStatus.Cancelled: + return ; + default: + return ; + } + }; + + const columns: GridColDef[] = [ + { field: "code", headerName: "Codice", width: 120 }, + { field: "description", headerName: "Descrizione", flex: 1, minWidth: 200 }, + { + field: "inventoryDate", + headerName: "Data Inventario", + width: 150, + valueFormatter: (value) => + value ? dayjs(value).format("DD/MM/YYYY") : "", + }, + { field: "warehouseName", headerName: "Magazzino", width: 180 }, + { field: "categoryName", headerName: "Categoria", width: 150 }, + { + field: "status", + headerName: "Stato", + width: 120, + renderCell: (params: GridRenderCellParams) => + getStatusChip(params.row.status), + }, + { + field: "progress", + headerName: "Progresso", + width: 150, + valueGetter: (_value, row) => { + if (!row.lineCount) return "0%"; + const percentage = Math.round( + (row.countedLineCount / row.lineCount) * 100 + ); + return `${row.countedLineCount}/${row.lineCount} (${percentage}%)`; + }, + }, + { + field: "actions", + headerName: "Azioni", + width: 180, + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + + handleView(params.row.id)}> + + + + {params.row.status === InventoryStatus.Draft && ( + + handleStart(params.row.id)} + > + + + + )} + {params.row.status === InventoryStatus.InProgress && ( + + handleStart(params.row.id)} + > + + + + )} + {params.row.status === InventoryStatus.Draft && ( + + { + if (confirm("Sei sicuro di voler annullare questo inventario?")) { + cancelMutation.mutate(params.row.id); + } + }} + > + + + + )} + + ), + }, + ]; + + return ( + + + Inventari Fisici + + + + + + + + ); +} diff --git a/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx b/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx index 65947a1..f7ef359 100644 --- a/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx +++ b/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx @@ -174,38 +174,30 @@ export default function WarehouseDashboard() { return ( {/* Header */} + {/* Actions */} - - - Magazzino - - - Dashboard e panoramica giacenze - - - - - - + + {/* Stats Cards */} @@ -313,11 +305,11 @@ export default function WarehouseDashboard() { size="small" color={ getMovementTypeColor(movement.type) as - | "success" - | "error" - | "info" - | "warning" - | "default" + | "success" + | "error" + | "info" + | "warning" + | "default" } /> diff --git a/frontend/src/modules/warehouse/pages/index.ts b/frontend/src/modules/warehouse/pages/index.ts index 203c1aa..32aeca4 100644 --- a/frontend/src/modules/warehouse/pages/index.ts +++ b/frontend/src/modules/warehouse/pages/index.ts @@ -16,3 +16,8 @@ export { default as TransferMovementPage } from './TransferMovementPage'; // Stock export { default as StockLevelsPage } from './StockLevelsPage'; + +// Inventory +export { default as InventoryListPage } from './InventoryListPage'; +export { default as InventoryFormPage } from './InventoryFormPage'; +export { default as InventoryCountPage } from './InventoryCountPage'; diff --git a/frontend/src/modules/warehouse/routes.tsx b/frontend/src/modules/warehouse/routes.tsx index 25dcb01..458b761 100644 --- a/frontend/src/modules/warehouse/routes.tsx +++ b/frontend/src/modules/warehouse/routes.tsx @@ -10,42 +10,55 @@ import { OutboundMovementPage, TransferMovementPage, StockLevelsPage, + InventoryListPage, + InventoryFormPage, + InventoryCountPage, } from "./pages"; +import WarehouseLayout from "./components/WarehouseLayout"; + export default function WarehouseRoutes() { return ( - {/* Dashboard */} - } /> + }> + {/* Dashboard */} + } /> - {/* Articles */} - } /> - } /> - } /> - } /> + {/* Articles */} + } /> + } /> + } /> + } /> - {/* Warehouse Locations */} - } /> + {/* Warehouse Locations */} + } /> - {/* Movements */} - } /> - } /> - } /> - } - /> - } - /> + {/* Movements */} + } /> + } /> + } /> + } + /> + } + /> - {/* Stock */} - } /> + {/* Stock */} + } /> - {/* Fallback */} - } /> + {/* Inventory */} + } /> + } /> + } /> + } /> + + {/* Fallback */} + } /> + ); diff --git a/src/Apollinare.API/apollinare.db-shm b/src/Apollinare.API/apollinare.db-shm new file mode 100644 index 0000000..ad2011c Binary files /dev/null and b/src/Apollinare.API/apollinare.db-shm differ diff --git a/src/Apollinare.API/apollinare.db-wal b/src/Apollinare.API/apollinare.db-wal new file mode 100644 index 0000000..f270da5 Binary files /dev/null and b/src/Apollinare.API/apollinare.db-wal differ