();
+ for (const level of stockLevels) {
+ const existing = grouped.get(level.warehouseId) || [];
+ existing.push(level);
+ grouped.set(level.warehouseId, existing);
+ }
+ return grouped;
+ }, [stockLevels]);
+
+ return {
+ summary,
+ getArticleStock,
+ groupByWarehouse,
+ };
+}
+
+/**
+ * Calculate valuation based on method
+ */
+export function calculateValuation(
+ movements: { quantity: number; unitCost: number; date: string }[],
+ method: ValuationMethod,
+ targetQuantity: number,
+): { totalCost: number; averageCost: number } {
+ if (targetQuantity <= 0 || movements.length === 0) {
+ return { totalCost: 0, averageCost: 0 };
+ }
+
+ // Sort movements by date
+ const sorted = [...movements].sort(
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
+ );
+
+ switch (method) {
+ case ValuationMethod.WeightedAverage: {
+ let totalQty = 0;
+ let totalCost = 0;
+ for (const m of sorted) {
+ totalQty += m.quantity;
+ totalCost += m.quantity * m.unitCost;
+ }
+ const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
+ return {
+ totalCost: targetQuantity * avgCost,
+ averageCost: avgCost,
+ };
+ }
+
+ case ValuationMethod.FIFO: {
+ // First In, First Out
+ let remaining = targetQuantity;
+ let totalCost = 0;
+ for (const m of sorted) {
+ if (remaining <= 0) break;
+ const take = Math.min(remaining, m.quantity);
+ totalCost += take * m.unitCost;
+ remaining -= take;
+ }
+ return {
+ totalCost,
+ averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
+ };
+ }
+
+ case ValuationMethod.LIFO: {
+ // Last In, First Out
+ const reversed = [...sorted].reverse();
+ let remaining = targetQuantity;
+ let totalCost = 0;
+ for (const m of reversed) {
+ if (remaining <= 0) break;
+ const take = Math.min(remaining, m.quantity);
+ totalCost += take * m.unitCost;
+ remaining -= take;
+ }
+ return {
+ totalCost,
+ averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
+ };
+ }
+
+ case ValuationMethod.StandardCost: {
+ // Use the most recent cost as standard
+ const lastMovement = sorted[sorted.length - 1];
+ const standardCost = lastMovement?.unitCost || 0;
+ return {
+ totalCost: targetQuantity * standardCost,
+ averageCost: standardCost,
+ };
+ }
+
+ case ValuationMethod.SpecificCost: {
+ // Specific cost requires batch/serial tracking
+ // For now, fall back to weighted average
+ let totalQty = 0;
+ let totalCost = 0;
+ for (const m of sorted) {
+ totalQty += m.quantity;
+ totalCost += m.quantity * m.unitCost;
+ }
+ const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
+ return {
+ totalCost: targetQuantity * avgCost,
+ averageCost: avgCost,
+ };
+ }
+
+ default:
+ return { totalCost: 0, averageCost: 0 };
+ }
+}
+
+/**
+ * Hook for article availability check
+ */
+export function useArticleAvailability(
+ article: ArticleDto | undefined,
+ stockLevels: StockLevelDto[] | undefined,
+ requestedQuantity: number,
+ warehouseId?: number,
+) {
+ return useMemo(() => {
+ if (!article || !stockLevels) {
+ return {
+ isAvailable: false,
+ availableQuantity: 0,
+ shortageQuantity: requestedQuantity,
+ message: "Dati non disponibili",
+ };
+ }
+
+ const relevantLevels = warehouseId
+ ? stockLevels.filter(
+ (l) => l.articleId === article.id && l.warehouseId === warehouseId,
+ )
+ : stockLevels.filter((l) => l.articleId === article.id);
+
+ const totalAvailable = relevantLevels.reduce(
+ (sum, l) =>
+ sum + (l.availableQuantity || l.quantity - l.reservedQuantity),
+ 0,
+ );
+
+ const isAvailable = totalAvailable >= requestedQuantity;
+ const shortageQuantity = Math.max(0, requestedQuantity - totalAvailable);
+
+ let message: string;
+ if (isAvailable) {
+ message = `Disponibile: ${totalAvailable} ${article.unitOfMeasure}`;
+ } else if (totalAvailable > 0) {
+ message = `Disponibile parzialmente: ${totalAvailable} ${article.unitOfMeasure} (mancano ${shortageQuantity})`;
+ } else {
+ message = "Non disponibile";
+ }
+
+ return {
+ isAvailable,
+ availableQuantity: totalAvailable,
+ shortageQuantity,
+ message,
+ };
+ }, [article, stockLevels, requestedQuantity, warehouseId]);
+}
diff --git a/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts b/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
new file mode 100644
index 0000000..86f5692
--- /dev/null
+++ b/frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
@@ -0,0 +1,160 @@
+import { useNavigate } from 'react-router-dom';
+import { useCallback } from 'react';
+
+/**
+ * Hook for navigating within the warehouse module
+ */
+export function useWarehouseNavigation() {
+ const navigate = useNavigate();
+
+ // Articles
+ const goToArticles = useCallback(() => {
+ navigate('/warehouse/articles');
+ }, [navigate]);
+
+ const goToArticle = useCallback((id: number) => {
+ navigate(`/warehouse/articles/${id}`);
+ }, [navigate]);
+
+ const goToNewArticle = useCallback(() => {
+ navigate('/warehouse/articles/new');
+ }, [navigate]);
+
+ const goToEditArticle = useCallback((id: number) => {
+ navigate(`/warehouse/articles/${id}/edit`);
+ }, [navigate]);
+
+ // Warehouses
+ const goToWarehouses = useCallback(() => {
+ navigate('/warehouse/locations');
+ }, [navigate]);
+
+ const goToWarehouse = useCallback((id: number) => {
+ navigate(`/warehouse/locations/${id}`);
+ }, [navigate]);
+
+ const goToNewWarehouse = useCallback(() => {
+ navigate('/warehouse/locations/new');
+ }, [navigate]);
+
+ // Categories
+ const goToCategories = useCallback(() => {
+ navigate('/warehouse/categories');
+ }, [navigate]);
+
+ // Movements
+ const goToMovements = useCallback(() => {
+ navigate('/warehouse/movements');
+ }, [navigate]);
+
+ const goToMovement = useCallback((id: number) => {
+ navigate(`/warehouse/movements/${id}`);
+ }, [navigate]);
+
+ const goToNewInbound = useCallback(() => {
+ navigate('/warehouse/movements/inbound/new');
+ }, [navigate]);
+
+ const goToNewOutbound = useCallback(() => {
+ navigate('/warehouse/movements/outbound/new');
+ }, [navigate]);
+
+ const goToNewTransfer = useCallback(() => {
+ navigate('/warehouse/movements/transfer/new');
+ }, [navigate]);
+
+ const goToNewAdjustment = useCallback(() => {
+ navigate('/warehouse/movements/adjustment/new');
+ }, [navigate]);
+
+ // Batches
+ const goToBatches = useCallback(() => {
+ navigate('/warehouse/batches');
+ }, [navigate]);
+
+ const goToBatch = useCallback((id: number) => {
+ navigate(`/warehouse/batches/${id}`);
+ }, [navigate]);
+
+ const goToNewBatch = useCallback(() => {
+ navigate('/warehouse/batches/new');
+ }, [navigate]);
+
+ // Serials
+ const goToSerials = useCallback(() => {
+ navigate('/warehouse/serials');
+ }, [navigate]);
+
+ const goToSerial = useCallback((id: number) => {
+ navigate(`/warehouse/serials/${id}`);
+ }, [navigate]);
+
+ const goToNewSerial = useCallback(() => {
+ navigate('/warehouse/serials/new');
+ }, [navigate]);
+
+ // Stock
+ const goToStockLevels = useCallback(() => {
+ navigate('/warehouse/stock');
+ }, [navigate]);
+
+ const goToValuation = useCallback(() => {
+ navigate('/warehouse/valuation');
+ }, [navigate]);
+
+ // Inventory
+ const goToInventories = useCallback(() => {
+ navigate('/warehouse/inventories');
+ }, [navigate]);
+
+ const goToInventory = useCallback((id: number) => {
+ navigate(`/warehouse/inventories/${id}`);
+ }, [navigate]);
+
+ const goToNewInventory = useCallback(() => {
+ navigate('/warehouse/inventories/new');
+ }, [navigate]);
+
+ // Dashboard
+ const goToDashboard = useCallback(() => {
+ navigate('/warehouse');
+ }, [navigate]);
+
+ return {
+ // Articles
+ goToArticles,
+ goToArticle,
+ goToNewArticle,
+ goToEditArticle,
+ // Warehouses
+ goToWarehouses,
+ goToWarehouse,
+ goToNewWarehouse,
+ // Categories
+ goToCategories,
+ // Movements
+ goToMovements,
+ goToMovement,
+ goToNewInbound,
+ goToNewOutbound,
+ goToNewTransfer,
+ goToNewAdjustment,
+ // Batches
+ goToBatches,
+ goToBatch,
+ goToNewBatch,
+ // Serials
+ goToSerials,
+ goToSerial,
+ goToNewSerial,
+ // Stock
+ goToStockLevels,
+ goToValuation,
+ // Inventory
+ goToInventories,
+ goToInventory,
+ goToNewInventory,
+ // Dashboard
+ goToDashboard,
+ };
+}
diff --git a/frontend/src/modules/warehouse/pages/ArticleFormPage.tsx b/frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
new file mode 100644
index 0000000..22aee9b
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
@@ -0,0 +1,900 @@
+import React, { useState, useEffect } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ Grid,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ FormControlLabel,
+ Switch,
+ Divider,
+ Alert,
+ CircularProgress,
+ IconButton,
+ Card,
+ CardMedia,
+ Tabs,
+ Tab,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Chip,
+ InputAdornment,
+} from "@mui/material";
+import {
+ ArrowBack as ArrowBackIcon,
+ Save as SaveIcon,
+ Upload as UploadIcon,
+ Delete as DeleteIcon,
+ Image as ImageIcon,
+} from "@mui/icons-material";
+import {
+ useArticle,
+ useCreateArticle,
+ useUpdateArticle,
+ useUploadArticleImage,
+ useCategoryTree,
+ useArticleStockLevels,
+ useArticleBatches,
+ useArticleSerials,
+} from "../hooks";
+import {
+ ValuationMethod,
+ StockManagementType,
+ valuationMethodLabels,
+ stockManagementTypeLabels,
+ formatCurrency,
+ formatQuantity,
+ formatDate,
+ CreateArticleDto,
+ UpdateArticleDto,
+ StockLevelDto,
+ BatchDto,
+ SerialDto,
+} from "../types";
+
+interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+function TabPanel(props: TabPanelProps) {
+ const { children, value, index, ...other } = props;
+ return (
+
+ {value === index && {children}}
+
+ );
+}
+
+export default function ArticleFormPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const isNew = !id || id === "new";
+ const articleId = isNew ? undefined : parseInt(id, 10);
+
+ const [tabValue, setTabValue] = useState(0);
+ const [formData, setFormData] = useState({
+ code: "",
+ description: "",
+ shortDescription: "",
+ categoryId: undefined as number | undefined,
+ unitOfMeasure: "PZ",
+ barcode: "",
+ minimumStock: 0,
+ maximumStock: 0,
+ reorderPoint: 0,
+ reorderQuantity: 0,
+ standardCost: 0,
+ stockManagement: StockManagementType.Standard,
+ valuationMethod: ValuationMethod.WeightedAverage,
+ isBatchManaged: false,
+ isSerialManaged: false,
+ hasExpiry: false,
+ expiryWarningDays: 30,
+ isActive: true,
+ notes: "",
+ });
+ const [imageFile, setImageFile] = useState(null);
+ const [imagePreview, setImagePreview] = useState(null);
+ const [errors, setErrors] = useState>({});
+
+ const { data: article, isLoading: loadingArticle } = useArticle(articleId);
+ const { data: categoryTree } = useCategoryTree();
+ const { data: stockLevels } = useArticleStockLevels(articleId);
+ const { data: batches } = useArticleBatches(articleId);
+ const { data: serials } = useArticleSerials(articleId);
+
+ const createMutation = useCreateArticle();
+ const updateMutation = useUpdateArticle();
+ const uploadImageMutation = useUploadArticleImage();
+
+ // Flatten category tree
+ const flatCategories = React.useMemo(() => {
+ const result: { id: number; name: string; level: number }[] = [];
+ const flatten = (categories: typeof categoryTree, level = 0) => {
+ if (!categories) return;
+ for (const cat of categories) {
+ result.push({ id: cat.id, name: cat.name, level });
+ if (cat.children) flatten(cat.children, level + 1);
+ }
+ };
+ flatten(categoryTree);
+ return result;
+ }, [categoryTree]);
+
+ // Load article data
+ useEffect(() => {
+ if (article) {
+ setFormData({
+ code: article.code,
+ description: article.description,
+ shortDescription: article.shortDescription || "",
+ categoryId: article.categoryId,
+ unitOfMeasure: article.unitOfMeasure,
+ barcode: article.barcode || "",
+ minimumStock: article.minimumStock || 0,
+ maximumStock: article.maximumStock || 0,
+ reorderPoint: article.reorderPoint || 0,
+ reorderQuantity: article.reorderQuantity || 0,
+ standardCost: article.standardCost || 0,
+ stockManagement: article.stockManagement,
+ valuationMethod:
+ article.valuationMethod || ValuationMethod.WeightedAverage,
+ isBatchManaged: article.isBatchManaged,
+ isSerialManaged: article.isSerialManaged,
+ hasExpiry: article.hasExpiry,
+ expiryWarningDays: article.expiryWarningDays || 30,
+ isActive: article.isActive,
+ notes: article.notes || "",
+ });
+ if (article.hasImage) {
+ setImagePreview(`/api/warehouse/articles/${article.id}/image`);
+ }
+ }
+ }, [article]);
+
+ const handleChange = (field: string, value: unknown) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ if (errors[field]) {
+ setErrors((prev) => ({ ...prev, [field]: "" }));
+ }
+ };
+
+ const handleImageChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ setImageFile(file);
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ setImagePreview(reader.result as string);
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleRemoveImage = () => {
+ setImageFile(null);
+ setImagePreview(null);
+ };
+
+ const validate = (): boolean => {
+ const newErrors: Record = {};
+ if (!formData.code.trim()) {
+ newErrors.code = "Il codice è obbligatorio";
+ }
+ if (!formData.description.trim()) {
+ newErrors.description = "La descrizione è obbligatoria";
+ }
+ if (!formData.unitOfMeasure.trim()) {
+ newErrors.unitOfMeasure = "L'unità di misura è obbligatoria";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!validate()) return;
+
+ try {
+ let savedId: number;
+ if (isNew) {
+ const createData: CreateArticleDto = {
+ code: formData.code,
+ description: formData.description,
+ shortDescription: formData.shortDescription || undefined,
+ categoryId: formData.categoryId,
+ unitOfMeasure: formData.unitOfMeasure,
+ barcode: formData.barcode || undefined,
+ minimumStock: formData.minimumStock,
+ maximumStock: formData.maximumStock,
+ reorderPoint: formData.reorderPoint,
+ reorderQuantity: formData.reorderQuantity,
+ standardCost: formData.standardCost,
+ stockManagement: formData.stockManagement,
+ valuationMethod: formData.valuationMethod,
+ isBatchManaged: formData.isBatchManaged,
+ isSerialManaged: formData.isSerialManaged,
+ hasExpiry: formData.hasExpiry,
+ expiryWarningDays: formData.expiryWarningDays,
+ notes: formData.notes || undefined,
+ };
+ const result = await createMutation.mutateAsync(createData);
+ savedId = result.id;
+ } else {
+ const updateData: UpdateArticleDto = {
+ code: formData.code,
+ description: formData.description,
+ shortDescription: formData.shortDescription || undefined,
+ categoryId: formData.categoryId,
+ unitOfMeasure: formData.unitOfMeasure,
+ barcode: formData.barcode || undefined,
+ minimumStock: formData.minimumStock,
+ maximumStock: formData.maximumStock,
+ reorderPoint: formData.reorderPoint,
+ reorderQuantity: formData.reorderQuantity,
+ standardCost: formData.standardCost,
+ stockManagement: formData.stockManagement,
+ valuationMethod: formData.valuationMethod,
+ isBatchManaged: formData.isBatchManaged,
+ isSerialManaged: formData.isSerialManaged,
+ hasExpiry: formData.hasExpiry,
+ expiryWarningDays: formData.expiryWarningDays,
+ isActive: formData.isActive,
+ notes: formData.notes || undefined,
+ };
+ await updateMutation.mutateAsync({ id: articleId!, data: updateData });
+ savedId = articleId!;
+ }
+
+ // Upload image if selected
+ if (imageFile) {
+ await uploadImageMutation.mutateAsync({ id: savedId, file: imageFile });
+ }
+
+ navigate(`/warehouse/articles/${savedId}`);
+ } catch (error) {
+ console.error("Errore salvataggio:", error);
+ }
+ };
+
+ const isPending =
+ createMutation.isPending ||
+ updateMutation.isPending ||
+ uploadImageMutation.isPending;
+
+ if (!isNew && loadingArticle) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ navigate(-1)}>
+
+
+
+ {isNew ? "Nuovo Articolo" : `Articolo: ${article?.code}`}
+
+
+
+ {(createMutation.error || updateMutation.error) && (
+
+ Errore durante il salvataggio:{" "}
+ {((createMutation.error || updateMutation.error) as Error).message}
+
+ )}
+
+ {!isNew && (
+
+ setTabValue(v)}>
+
+
+ {article?.isBatchManaged && }
+ {article?.isSerialManaged && }
+
+
+ )}
+
+
+
+
+
+ {/* Stock Levels Tab */}
+
+
+
+ Giacenze per Magazzino
+
+
+
+
+
+ Magazzino
+ Quantità
+ Riservata
+ Disponibile
+ Valore
+
+
+
+ {!stockLevels || stockLevels.length === 0 ? (
+
+
+
+ Nessuna giacenza
+
+
+
+ ) : (
+ stockLevels.map((level: StockLevelDto) => (
+
+ {level.warehouseName}
+
+ {formatQuantity(level.quantity)}{" "}
+ {article?.unitOfMeasure}
+
+
+ {formatQuantity(level.reservedQuantity)}{" "}
+ {article?.unitOfMeasure}
+
+
+ {formatQuantity(level.availableQuantity)}{" "}
+ {article?.unitOfMeasure}
+
+
+ {formatCurrency(level.stockValue)}
+
+
+ ))
+ )}
+
+
+
+
+
+
+ {/* Batches Tab */}
+ {article?.isBatchManaged && (
+
+
+
+ Lotti
+
+
+
+
+
+ Numero Lotto
+ Quantità
+ Data Scadenza
+ Stato
+
+
+
+ {!batches || batches.length === 0 ? (
+
+
+
+ Nessun lotto
+
+
+
+ ) : (
+ batches.map((batch: BatchDto) => (
+
+ {batch.batchNumber}
+
+ {formatQuantity(batch.currentQuantity)}{" "}
+ {article?.unitOfMeasure}
+
+
+ {batch.expiryDate
+ ? formatDate(batch.expiryDate)
+ : "-"}
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ )}
+
+ {/* Serials Tab */}
+ {article?.isSerialManaged && (
+
+
+
+ Matricole
+
+
+
+
+
+ Matricola
+ Magazzino
+ Lotto
+ Stato
+
+
+
+ {!serials || serials.length === 0 ? (
+
+
+
+ Nessuna matricola
+
+
+
+ ) : (
+ serials.map((serial: SerialDto) => (
+
+ {serial.serialNumber}
+
+ {serial.currentWarehouseName || "-"}
+
+ {serial.batchNumber || "-"}
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/ArticlesPage.tsx b/frontend/src/modules/warehouse/pages/ArticlesPage.tsx
new file mode 100644
index 0000000..631169e
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/ArticlesPage.tsx
@@ -0,0 +1,523 @@
+import React, { useState, useMemo } from "react";
+import {
+ Box,
+ Paper,
+ Typography,
+ Button,
+ TextField,
+ InputAdornment,
+ IconButton,
+ Chip,
+ Menu,
+ MenuItem,
+ ListItemIcon,
+ ListItemText,
+ Tooltip,
+ Card,
+ CardContent,
+ CardMedia,
+ CardActions,
+ Grid,
+ ToggleButton,
+ ToggleButtonGroup,
+ FormControl,
+ InputLabel,
+ Select,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ Skeleton,
+} from "@mui/material";
+import {
+ Add as AddIcon,
+ Search as SearchIcon,
+ Clear as ClearIcon,
+ ViewList as ViewListIcon,
+ ViewModule as ViewModuleIcon,
+ MoreVert as MoreVertIcon,
+ Edit as EditIcon,
+ Delete as DeleteIcon,
+ Inventory as InventoryIcon,
+ FilterList as FilterListIcon,
+ Image as ImageIcon,
+} from "@mui/icons-material";
+import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
+import { useArticles, useDeleteArticle, useCategoryTree } from "../hooks";
+import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
+import { ArticleDto, formatCurrency } from "../types";
+
+type ViewMode = "list" | "grid";
+
+export default function ArticlesPage() {
+ const [viewMode, setViewMode] = useState("list");
+ const [search, setSearch] = useState("");
+ const [categoryId, setCategoryId] = useState("");
+ const [showInactive, setShowInactive] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [articleToDelete, setArticleToDelete] = useState(
+ null,
+ );
+ const [menuAnchor, setMenuAnchor] = useState(null);
+ const [menuArticle, setMenuArticle] = useState(null);
+
+ const nav = useWarehouseNavigation();
+
+ const {
+ data: articles,
+ isLoading,
+ error,
+ } = useArticles({
+ categoryId: categoryId || undefined,
+ search: search || undefined,
+ isActive: showInactive ? undefined : true,
+ });
+
+ const { data: categoryTree } = useCategoryTree();
+
+ const deleteMutation = useDeleteArticle();
+
+ // Flatten category tree for select
+ const flatCategories = useMemo(() => {
+ const result: { id: number; name: string; level: number }[] = [];
+ const flatten = (categories: typeof categoryTree, level = 0) => {
+ if (!categories) return;
+ for (const cat of categories) {
+ result.push({ id: cat.id, name: cat.name, level });
+ if (cat.children) flatten(cat.children, level + 1);
+ }
+ };
+ flatten(categoryTree);
+ return result;
+ }, [categoryTree]);
+
+ const handleMenuOpen = (
+ event: React.MouseEvent,
+ article: ArticleDto,
+ ) => {
+ event.stopPropagation();
+ setMenuAnchor(event.currentTarget);
+ setMenuArticle(article);
+ };
+
+ const handleMenuClose = () => {
+ setMenuAnchor(null);
+ setMenuArticle(null);
+ };
+
+ const handleEdit = () => {
+ if (menuArticle) {
+ nav.goToEditArticle(menuArticle.id);
+ }
+ handleMenuClose();
+ };
+
+ const handleDeleteClick = () => {
+ setArticleToDelete(menuArticle);
+ setDeleteDialogOpen(true);
+ handleMenuClose();
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (articleToDelete) {
+ await deleteMutation.mutateAsync(articleToDelete.id);
+ setDeleteDialogOpen(false);
+ setArticleToDelete(null);
+ }
+ };
+
+ const handleViewStock = () => {
+ if (menuArticle) {
+ nav.goToArticle(menuArticle.id);
+ }
+ handleMenuClose();
+ };
+
+ const columns: GridColDef[] = [
+ {
+ field: "hasImage",
+ headerName: "",
+ width: 60,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {params.row.hasImage ? (
+
+ ) : (
+
+ )}
+
+ ),
+ },
+ {
+ field: "code",
+ headerName: "Codice",
+ width: 120,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {params.value}
+
+ ),
+ },
+ {
+ field: "description",
+ headerName: "Descrizione",
+ flex: 1,
+ minWidth: 200,
+ },
+ {
+ field: "categoryName",
+ headerName: "Categoria",
+ width: 150,
+ },
+ {
+ field: "unitOfMeasure",
+ headerName: "U.M.",
+ width: 80,
+ align: "center",
+ },
+ {
+ field: "weightedAverageCost",
+ headerName: "Costo Medio",
+ width: 120,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) =>
+ formatCurrency(params.value || 0),
+ },
+ {
+ field: "isActive",
+ headerName: "Stato",
+ width: 100,
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: "actions",
+ headerName: "",
+ width: 60,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => (
+ handleMenuOpen(e, params.row)}>
+
+
+ ),
+ },
+ ];
+
+ if (error) {
+ return (
+
+
+ Errore nel caricamento degli articoli: {(error as Error).message}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Anagrafica Articoli
+
+ }
+ onClick={nav.goToNewArticle}
+ >
+ Nuovo Articolo
+
+
+
+ {/* Filters */}
+
+
+
+ setSearch(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: search && (
+
+ setSearch("")}>
+
+
+
+ ),
+ }}
+ />
+
+
+
+ Categoria
+
+
+
+
+ }
+ onClick={() => setShowInactive(!showInactive)}
+ fullWidth
+ >
+ {showInactive ? "Mostra Tutti" : "Solo Attivi"}
+
+
+
+ value && setViewMode(value)}
+ size="small"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+ {viewMode === "list" ? (
+
+ nav.goToArticle(params.row.id)}
+ disableRowSelectionOnClick
+ sx={{
+ "& .MuiDataGrid-row:hover": {
+ cursor: "pointer",
+ },
+ }}
+ />
+
+ ) : (
+
+ {isLoading ? (
+ Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))
+ ) : articles?.length === 0 ? (
+
+
+
+
+ Nessun articolo trovato
+
+
+
+ ) : (
+ articles?.map((article) => (
+
+ nav.goToArticle(article.id)}
+ >
+ {article.hasImage ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {article.code}
+
+
+ {article.description}
+
+
+ {article.categoryName && (
+
+ )}
+
+
+
+
+ handleMenuOpen(e, article)}
+ sx={{ ml: "auto" }}
+ >
+
+
+
+
+
+ ))
+ )}
+
+ )}
+
+ {/* Context Menu */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/InboundMovementPage.tsx b/frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
new file mode 100644
index 0000000..91272da
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
@@ -0,0 +1,454 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ Grid,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Alert,
+ CircularProgress,
+ Autocomplete,
+ InputAdornment,
+ Divider,
+ Chip,
+} from "@mui/material";
+import {
+ ArrowBack as ArrowBackIcon,
+ Save as SaveIcon,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+ Check as ConfirmIcon,
+} from "@mui/icons-material";
+import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import dayjs, { Dayjs } from "dayjs";
+import "dayjs/locale/it";
+import {
+ useWarehouses,
+ useArticles,
+ useCreateInboundMovement,
+ useConfirmMovement,
+} from "../hooks";
+import { ArticleDto, CreateMovementDto, formatCurrency } from "../types";
+
+interface MovementLine {
+ id: string;
+ article: ArticleDto | null;
+ quantity: number;
+ unitCost: number;
+ notes?: string;
+}
+
+export default function InboundMovementPage() {
+ const navigate = useNavigate();
+ const [movementDate, setMovementDate] = useState(dayjs());
+ const [warehouseId, setWarehouseId] = useState("");
+ const [documentNumber, setDocumentNumber] = useState("");
+ const [externalReference, setExternalReference] = useState("");
+ const [notes, setNotes] = useState("");
+ const [lines, setLines] = useState([
+ { id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
+ ]);
+ const [errors, setErrors] = useState>({});
+
+ const { data: warehouses } = useWarehouses({ active: true });
+ const { data: articles } = useArticles({ isActive: true });
+ const createMutation = useCreateInboundMovement();
+ const confirmMutation = useConfirmMovement();
+
+ // Set default warehouse
+ React.useEffect(() => {
+ if (warehouses && warehouseId === "") {
+ const defaultWarehouse = warehouses.find((w) => w.isDefault);
+ if (defaultWarehouse) {
+ setWarehouseId(defaultWarehouse.id);
+ }
+ }
+ }, [warehouses, warehouseId]);
+
+ const handleAddLine = () => {
+ setLines([
+ ...lines,
+ { id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
+ ]);
+ };
+
+ const handleRemoveLine = (id: string) => {
+ if (lines.length > 1) {
+ setLines(lines.filter((l) => l.id !== id));
+ }
+ };
+
+ const handleLineChange = (
+ id: string,
+ field: keyof MovementLine,
+ value: unknown,
+ ) => {
+ setLines(
+ lines.map((l) => {
+ if (l.id === id) {
+ const updated = { ...l, [field]: value };
+ // Auto-fill unit cost from article
+ if (field === "article" && value) {
+ const article = value as ArticleDto;
+ updated.unitCost =
+ article.lastPurchaseCost || article.weightedAverageCost || 0;
+ }
+ return updated;
+ }
+ return l;
+ }),
+ );
+ };
+
+ const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
+ const totalValue = lines.reduce(
+ (sum, l) => sum + (l.quantity || 0) * (l.unitCost || 0),
+ 0,
+ );
+
+ const validate = (): boolean => {
+ const newErrors: Record = {};
+ if (!warehouseId) {
+ newErrors.warehouseId = "Seleziona un magazzino";
+ }
+ if (!movementDate) {
+ newErrors.movementDate = "Inserisci la data";
+ }
+ const validLines = lines.filter((l) => l.article && l.quantity > 0);
+ if (validLines.length === 0) {
+ newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (andConfirm: boolean = false) => {
+ if (!validate()) return;
+
+ const data: CreateMovementDto = {
+ warehouseId: warehouseId as number,
+ movementDate: movementDate!.format("YYYY-MM-DD"),
+ documentNumber: documentNumber || undefined,
+ externalReference: externalReference || undefined,
+ notes: notes || undefined,
+ lines: lines
+ .filter((l) => l.article && l.quantity > 0)
+ .map((l) => ({
+ articleId: l.article!.id,
+ quantity: l.quantity,
+ unitCost: l.unitCost,
+ notes: l.notes,
+ })),
+ };
+
+ try {
+ const result = await createMutation.mutateAsync(data);
+ if (andConfirm) {
+ await confirmMutation.mutateAsync(result.id);
+ }
+ navigate(`/warehouse/movements/${result.id}`);
+ } catch (error) {
+ console.error("Errore salvataggio:", error);
+ }
+ };
+
+ const isPending = createMutation.isPending || confirmMutation.isPending;
+
+ return (
+
+
+ {/* Header */}
+
+ navigate(-1)}>
+
+
+
+
+ Nuovo Carico
+
+
+ Movimento di entrata merce in magazzino
+
+
+
+
+ {(createMutation.error || confirmMutation.error) && (
+
+ Errore:{" "}
+ {((createMutation.error || confirmMutation.error) as Error).message}
+
+ )}
+
+ {/* Form Header */}
+
+
+ Dati Movimento
+
+
+
+
+
+
+
+ Magazzino
+
+ {errors.warehouseId && (
+
+ {errors.warehouseId}
+
+ )}
+
+
+
+ setDocumentNumber(e.target.value)}
+ placeholder="DDT, Fattura, etc."
+ />
+
+
+ setExternalReference(e.target.value)}
+ placeholder="Ordine, Fornitore, etc."
+ />
+
+
+ setNotes(e.target.value)}
+ multiline
+ rows={2}
+ />
+
+
+
+
+ {/* Lines */}
+
+
+ Righe Movimento
+ } onClick={handleAddLine}>
+ Aggiungi Riga
+
+
+
+ {errors.lines && (
+
+ {errors.lines}
+
+ )}
+
+
+
+
+
+ Articolo
+
+ Quantità
+
+
+ Costo Unitario
+
+
+ Totale
+
+
+
+
+
+ {lines.map((line) => (
+
+
+
+ handleLineChange(line.id, "article", value)
+ }
+ options={articles || []}
+ getOptionLabel={(option) =>
+ `${option.code} - ${option.description}`
+ }
+ renderInput={(params) => (
+
+ )}
+ isOptionEqualToValue={(option, value) =>
+ option.id === value.id
+ }
+ />
+
+
+
+ handleLineChange(
+ line.id,
+ "quantity",
+ parseFloat(e.target.value) || 0,
+ )
+ }
+ slotProps={{
+ htmlInput: { min: 0, step: 0.01 },
+ input: {
+ endAdornment: line.article && (
+
+ {line.article.unitOfMeasure}
+
+ ),
+ },
+ }}
+ fullWidth
+ />
+
+
+
+ handleLineChange(
+ line.id,
+ "unitCost",
+ parseFloat(e.target.value) || 0,
+ )
+ }
+ slotProps={{
+ htmlInput: { min: 0, step: 0.01 },
+ input: {
+ startAdornment: (
+
+ €
+
+ ),
+ },
+ }}
+ fullWidth
+ />
+
+
+
+ {formatCurrency(line.quantity * line.unitCost)}
+
+
+
+ handleRemoveLine(line.id)}
+ disabled={lines.length === 1}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {/* Totals */}
+
+
+
+ Totale Quantità
+
+ {totalQuantity.toFixed(2)}
+
+
+
+ Totale Valore
+
+ {formatCurrency(totalValue)}
+
+
+
+
+ {/* Actions */}
+
+
+ :
+ }
+ onClick={() => handleSubmit(false)}
+ disabled={isPending}
+ >
+ Salva Bozza
+
+ :
+ }
+ onClick={() => handleSubmit(true)}
+ disabled={isPending}
+ color="success"
+ >
+ Salva e Conferma
+
+
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/MovementsPage.tsx b/frontend/src/modules/warehouse/pages/MovementsPage.tsx
new file mode 100644
index 0000000..be66c5e
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/MovementsPage.tsx
@@ -0,0 +1,650 @@
+import React, { useState } from "react";
+import {
+ Box,
+ Paper,
+ Typography,
+ Button,
+ TextField,
+ InputAdornment,
+ IconButton,
+ Chip,
+ Menu,
+ MenuItem,
+ ListItemIcon,
+ ListItemText,
+ FormControl,
+ InputLabel,
+ Select,
+ Grid,
+ Alert,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ SpeedDial,
+ SpeedDialAction,
+ SpeedDialIcon,
+} from "@mui/material";
+import {
+ Search as SearchIcon,
+ Clear as ClearIcon,
+ MoreVert as MoreVertIcon,
+ Visibility as ViewIcon,
+ Check as ConfirmIcon,
+ Close as CancelIcon,
+ Delete as DeleteIcon,
+ Add as AddIcon,
+ Download as DownloadIcon,
+ Upload as UploadIcon,
+ SwapHoriz as TransferIcon,
+ Build as AdjustmentIcon,
+ FilterList as FilterIcon,
+} from "@mui/icons-material";
+import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
+import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import { Dayjs } from "dayjs";
+import "dayjs/locale/it";
+import {
+ useMovements,
+ useWarehouses,
+ useConfirmMovement,
+ useCancelMovement,
+ useDeleteMovement,
+} from "../hooks";
+import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
+import {
+ MovementDto,
+ MovementType,
+ MovementStatus,
+ movementTypeLabels,
+ movementStatusLabels,
+ getMovementTypeColor,
+ getMovementStatusColor,
+ formatDate,
+} from "../types";
+
+export default function MovementsPage() {
+ const [search, setSearch] = useState("");
+ const [warehouseId, setWarehouseId] = useState("");
+ const [movementType, setMovementType] = useState("");
+ const [status, setStatus] = useState("");
+ const [dateFrom, setDateFrom] = useState(null);
+ const [dateTo, setDateTo] = useState(null);
+ const [menuAnchor, setMenuAnchor] = useState(null);
+ const [menuMovement, setMenuMovement] = useState(null);
+ const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+ const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [speedDialOpen, setSpeedDialOpen] = useState(false);
+
+ const nav = useWarehouseNavigation();
+
+ const {
+ data: movements,
+ isLoading,
+ error,
+ } = useMovements({
+ warehouseId: warehouseId || undefined,
+ type: movementType !== "" ? movementType : undefined,
+ status: status !== "" ? status : undefined,
+ dateFrom: dateFrom?.format("YYYY-MM-DD"),
+ dateTo: dateTo?.format("YYYY-MM-DD"),
+ });
+
+ const { data: warehouses } = useWarehouses();
+ const confirmMutation = useConfirmMovement();
+ const cancelMutation = useCancelMovement();
+ const deleteMutation = useDeleteMovement();
+
+ // Filter movements by search
+ const filteredMovements = React.useMemo(() => {
+ if (!movements) return [];
+ if (!search) return movements;
+ const lower = search.toLowerCase();
+ return movements.filter(
+ (m) =>
+ m.documentNumber?.toLowerCase().includes(lower) ||
+ m.externalReference?.toLowerCase().includes(lower) ||
+ m.notes?.toLowerCase().includes(lower),
+ );
+ }, [movements, search]);
+
+ const handleMenuOpen = (
+ event: React.MouseEvent,
+ movement: MovementDto,
+ ) => {
+ event.stopPropagation();
+ setMenuAnchor(event.currentTarget);
+ setMenuMovement(movement);
+ };
+
+ const handleMenuClose = () => {
+ setMenuAnchor(null);
+ setMenuMovement(null);
+ };
+
+ const handleView = () => {
+ if (menuMovement) {
+ nav.goToMovement(menuMovement.id);
+ }
+ handleMenuClose();
+ };
+
+ const handleConfirmClick = () => {
+ setConfirmDialogOpen(true);
+ setMenuAnchor(null);
+ };
+
+ const handleCancelClick = () => {
+ setCancelDialogOpen(true);
+ setMenuAnchor(null);
+ };
+
+ const handleDeleteClick = () => {
+ setDeleteDialogOpen(true);
+ setMenuAnchor(null);
+ };
+
+ const handleConfirmMovement = async () => {
+ if (menuMovement) {
+ await confirmMutation.mutateAsync(menuMovement.id);
+ setConfirmDialogOpen(false);
+ setMenuMovement(null);
+ }
+ };
+
+ const handleCancelMovement = async () => {
+ if (menuMovement) {
+ await cancelMutation.mutateAsync(menuMovement.id);
+ setCancelDialogOpen(false);
+ setMenuMovement(null);
+ }
+ };
+
+ const handleDeleteMovement = async () => {
+ if (menuMovement) {
+ await deleteMutation.mutateAsync(menuMovement.id);
+ setDeleteDialogOpen(false);
+ setMenuMovement(null);
+ }
+ };
+
+ const clearFilters = () => {
+ setSearch("");
+ setWarehouseId("");
+ setMovementType("");
+ setStatus("");
+ setDateFrom(null);
+ setDateTo(null);
+ };
+
+ const columns: GridColDef[] = [
+ {
+ field: "documentNumber",
+ headerName: "Documento",
+ width: 140,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {params.value || "-"}
+
+ ),
+ },
+ {
+ field: "movementDate",
+ headerName: "Data",
+ width: 110,
+ renderCell: (params: GridRenderCellParams) =>
+ formatDate(params.value),
+ },
+ {
+ field: "type",
+ headerName: "Tipo",
+ width: 130,
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: "status",
+ headerName: "Stato",
+ width: 120,
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: "sourceWarehouseName",
+ headerName: "Magazzino",
+ width: 150,
+ renderCell: (params: GridRenderCellParams) =>
+ params.row.sourceWarehouseName ||
+ params.row.destinationWarehouseName ||
+ "-",
+ },
+ {
+ field: "destinationWarehouseName",
+ headerName: "Destinazione",
+ width: 150,
+ renderCell: (params: GridRenderCellParams) => {
+ // Show destination only for transfers
+ if (params.row.type === MovementType.Transfer) {
+ return params.row.destinationWarehouseName || "-";
+ }
+ return "-";
+ },
+ },
+ {
+ field: "reasonDescription",
+ headerName: "Causale",
+ width: 150,
+ renderCell: (params: GridRenderCellParams) =>
+ params.value || "-",
+ },
+ {
+ field: "lineCount",
+ headerName: "Righe",
+ width: 80,
+ align: "center",
+ },
+ {
+ field: "totalValue",
+ headerName: "Valore",
+ width: 100,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) =>
+ params.value != null
+ ? new Intl.NumberFormat("it-IT", {
+ style: "currency",
+ currency: "EUR",
+ }).format(params.value)
+ : "-",
+ },
+ {
+ field: "externalReference",
+ headerName: "Riferimento",
+ width: 140,
+ renderCell: (params: GridRenderCellParams) =>
+ params.value || "-",
+ },
+ {
+ field: "actions",
+ headerName: "",
+ width: 60,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => (
+ handleMenuOpen(e, params.row)}>
+
+
+ ),
+ },
+ ];
+
+ const speedDialActions = [
+ { icon: , name: "Carico", action: nav.goToNewInbound },
+ { icon: , name: "Scarico", action: nav.goToNewOutbound },
+ {
+ icon: ,
+ name: "Trasferimento",
+ action: nav.goToNewTransfer,
+ },
+ {
+ icon: ,
+ name: "Rettifica",
+ action: nav.goToNewAdjustment,
+ },
+ ];
+
+ if (error) {
+ return (
+
+
+ Errore nel caricamento dei movimenti: {(error as Error).message}
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ Movimenti di Magazzino
+
+
+
+ {/* Filters */}
+
+
+
+ setSearch(e.target.value)}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: search && (
+
+ setSearch("")}>
+
+
+
+ ),
+ },
+ }}
+ />
+
+
+
+ Magazzino
+
+
+
+
+
+ Tipo
+
+
+
+
+
+ Stato
+
+
+
+
+
+
+
+
+
+ {(search ||
+ warehouseId ||
+ movementType !== "" ||
+ status !== "" ||
+ dateFrom ||
+ dateTo) && (
+
+ }
+ onClick={clearFilters}
+ >
+ Reset
+
+
+ )}
+
+
+
+ {/* Data Grid */}
+
+ nav.goToMovement(params.row.id)}
+ disableRowSelectionOnClick
+ sx={{
+ "& .MuiDataGrid-row:hover": {
+ cursor: "pointer",
+ },
+ }}
+ />
+
+
+ {/* Speed Dial for New Movements */}
+ } />}
+ open={speedDialOpen}
+ onOpen={() => setSpeedDialOpen(true)}
+ onClose={() => setSpeedDialOpen(false)}
+ >
+ {speedDialActions.map((action) => (
+ {
+ setSpeedDialOpen(false);
+ action.action();
+ }}
+ />
+ ))}
+
+
+ {/* Context Menu */}
+
+
+ {/* Confirm Movement Dialog */}
+
+
+ {/* Cancel Movement Dialog */}
+
+
+ {/* Delete Movement Dialog */}
+
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx b/frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
new file mode 100644
index 0000000..b39de61
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
@@ -0,0 +1,481 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ Grid,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Alert,
+ CircularProgress,
+ Autocomplete,
+ InputAdornment,
+ Divider,
+ Chip,
+ Tooltip,
+} from "@mui/material";
+import {
+ ArrowBack as ArrowBackIcon,
+ Save as SaveIcon,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+ Check as ConfirmIcon,
+ Warning as WarningIcon,
+} from "@mui/icons-material";
+import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import dayjs, { Dayjs } from "dayjs";
+import "dayjs/locale/it";
+import {
+ useWarehouses,
+ useArticles,
+ useStockLevels,
+ useCreateOutboundMovement,
+ useConfirmMovement,
+} from "../hooks";
+import { ArticleDto, CreateMovementDto, formatQuantity } from "../types";
+
+interface MovementLine {
+ id: string;
+ article: ArticleDto | null;
+ quantity: number;
+ notes?: string;
+ availableQty?: number;
+}
+
+export default function OutboundMovementPage() {
+ const navigate = useNavigate();
+ const [movementDate, setMovementDate] = useState(dayjs());
+ const [warehouseId, setWarehouseId] = useState("");
+ const [documentNumber, setDocumentNumber] = useState("");
+ const [externalReference, setExternalReference] = useState("");
+ const [notes, setNotes] = useState("");
+ const [lines, setLines] = useState([
+ { id: crypto.randomUUID(), article: null, quantity: 1 },
+ ]);
+ const [errors, setErrors] = useState>({});
+
+ const { data: warehouses } = useWarehouses({ active: true });
+ const { data: articles } = useArticles({ isActive: true });
+ const { data: stockLevels } = useStockLevels({
+ warehouseId: warehouseId || undefined,
+ });
+ const createMutation = useCreateOutboundMovement();
+ const confirmMutation = useConfirmMovement();
+
+ // Set default warehouse
+ React.useEffect(() => {
+ if (warehouses && warehouseId === "") {
+ const defaultWarehouse = warehouses.find((w) => w.isDefault);
+ if (defaultWarehouse) {
+ setWarehouseId(defaultWarehouse.id);
+ }
+ }
+ }, [warehouses, warehouseId]);
+
+ // Get available quantity for an article
+ const getAvailableQty = (articleId: number): number => {
+ if (!stockLevels) return 0;
+ const level = stockLevels.find((l) => l.articleId === articleId);
+ return level ? level.availableQuantity : 0;
+ };
+
+ const handleAddLine = () => {
+ setLines([
+ ...lines,
+ { id: crypto.randomUUID(), article: null, quantity: 1 },
+ ]);
+ };
+
+ const handleRemoveLine = (id: string) => {
+ if (lines.length > 1) {
+ setLines(lines.filter((l) => l.id !== id));
+ }
+ };
+
+ const handleLineChange = (
+ id: string,
+ field: keyof MovementLine,
+ value: unknown,
+ ) => {
+ setLines(
+ lines.map((l) => {
+ if (l.id === id) {
+ const updated = { ...l, [field]: value };
+ // Update available qty when article changes
+ if (field === "article" && value) {
+ const article = value as ArticleDto;
+ updated.availableQty = getAvailableQty(article.id);
+ }
+ return updated;
+ }
+ return l;
+ }),
+ );
+ };
+
+ // Check for stock issues
+ const hasStockIssues = lines.some(
+ (l) =>
+ l.article && l.availableQty !== undefined && l.quantity > l.availableQty,
+ );
+
+ const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
+
+ const validate = (): boolean => {
+ const newErrors: Record = {};
+ if (!warehouseId) {
+ newErrors.warehouseId = "Seleziona un magazzino";
+ }
+ if (!movementDate) {
+ newErrors.movementDate = "Inserisci la data";
+ }
+ const validLines = lines.filter((l) => l.article && l.quantity > 0);
+ if (validLines.length === 0) {
+ newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (andConfirm: boolean = false) => {
+ if (!validate()) return;
+
+ const data: CreateMovementDto = {
+ warehouseId: warehouseId as number,
+ movementDate: movementDate!.format("YYYY-MM-DD"),
+ documentNumber: documentNumber || undefined,
+ externalReference: externalReference || undefined,
+ notes: notes || undefined,
+ lines: lines
+ .filter((l) => l.article && l.quantity > 0)
+ .map((l) => ({
+ articleId: l.article!.id,
+ quantity: l.quantity,
+ notes: l.notes,
+ })),
+ };
+
+ try {
+ const result = await createMutation.mutateAsync(data);
+ if (andConfirm) {
+ await confirmMutation.mutateAsync(result.id);
+ }
+ navigate(`/warehouse/movements/${result.id}`);
+ } catch (error) {
+ console.error("Errore salvataggio:", error);
+ }
+ };
+
+ const isPending = createMutation.isPending || confirmMutation.isPending;
+
+ return (
+
+
+ {/* Header */}
+
+ navigate(-1)}>
+
+
+
+
+ Nuovo Scarico
+
+
+ Movimento di uscita merce da magazzino
+
+
+
+
+ {(createMutation.error || confirmMutation.error) && (
+
+ Errore:{" "}
+ {((createMutation.error || confirmMutation.error) as Error).message}
+
+ )}
+
+ {hasStockIssues && (
+ }>
+ Attenzione: alcune righe superano la disponibilità in magazzino
+
+ )}
+
+ {/* Form Header */}
+
+
+ Dati Movimento
+
+
+
+
+
+
+
+ Magazzino
+
+ {errors.warehouseId && (
+
+ {errors.warehouseId}
+
+ )}
+
+
+
+ setDocumentNumber(e.target.value)}
+ placeholder="DDT, Bolla, etc."
+ />
+
+
+ setExternalReference(e.target.value)}
+ placeholder="Ordine, Cliente, etc."
+ />
+
+
+ setNotes(e.target.value)}
+ multiline
+ rows={2}
+ />
+
+
+
+
+ {/* Lines */}
+
+
+ Righe Movimento
+ } onClick={handleAddLine}>
+ Aggiungi Riga
+
+
+
+ {errors.lines && (
+
+ {errors.lines}
+
+ )}
+
+
+
+
+
+ Articolo
+
+ Disponibile
+
+
+ Quantità
+
+ Note
+
+
+
+
+ {lines.map((line) => {
+ const isOverStock =
+ line.article &&
+ line.availableQty !== undefined &&
+ line.quantity > line.availableQty;
+ return (
+
+
+
+ handleLineChange(line.id, "article", value)
+ }
+ options={articles || []}
+ getOptionLabel={(option) =>
+ `${option.code} - ${option.description}`
+ }
+ renderInput={(params) => (
+
+ )}
+ isOptionEqualToValue={(option, value) =>
+ option.id === value.id
+ }
+ />
+
+
+ {line.article ? (
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+ handleLineChange(
+ line.id,
+ "quantity",
+ parseFloat(e.target.value) || 0,
+ )
+ }
+ slotProps={{
+ htmlInput: { min: 0, step: 0.01 },
+ input: {
+ endAdornment: line.article && (
+
+ {line.article.unitOfMeasure}
+
+ ),
+ },
+ }}
+ error={isOverStock ?? undefined}
+ fullWidth
+ />
+ {isOverStock && (
+
+
+
+ )}
+
+
+
+
+ handleLineChange(line.id, "notes", e.target.value)
+ }
+ fullWidth
+ />
+
+
+ handleRemoveLine(line.id)}
+ disabled={lines.length === 1}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ {/* Totals */}
+
+
+
+ Totale Quantità
+
+ {totalQuantity.toFixed(2)}
+
+
+
+
+ {/* Actions */}
+
+
+ :
+ }
+ onClick={() => handleSubmit(false)}
+ disabled={isPending}
+ >
+ Salva Bozza
+
+ :
+ }
+ onClick={() => handleSubmit(true)}
+ disabled={isPending || hasStockIssues}
+ color="success"
+ >
+ Salva e Conferma
+
+
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/StockLevelsPage.tsx b/frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
new file mode 100644
index 0000000..9cfb205
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
@@ -0,0 +1,355 @@
+import React, { useState } from "react";
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ InputAdornment,
+ IconButton,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Grid,
+ Alert,
+ Chip,
+ FormControlLabel,
+ Switch,
+ Button,
+ Card,
+ CardContent,
+} from "@mui/material";
+import {
+ Search as SearchIcon,
+ Clear as ClearIcon,
+ Warning as WarningIcon,
+ TrendingUp as TrendingUpIcon,
+} from "@mui/icons-material";
+import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
+import { useStockLevels, useWarehouses, useCategoryTree } from "../hooks";
+import { useStockCalculations } from "../hooks/useStockCalculations";
+import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
+import { StockLevelDto, formatCurrency, formatQuantity } from "../types";
+
+export default function StockLevelsPage() {
+ const [search, setSearch] = useState("");
+ const [warehouseId, setWarehouseId] = useState("");
+ const [categoryId, setCategoryId] = useState("");
+ const [lowStockOnly, setLowStockOnly] = useState(false);
+
+ const nav = useWarehouseNavigation();
+
+ const {
+ data: stockLevels,
+ isLoading,
+ error,
+ } = useStockLevels({
+ warehouseId: warehouseId || undefined,
+ categoryId: categoryId || undefined,
+ onlyLowStock: lowStockOnly || undefined,
+ });
+
+ const { data: warehouses } = useWarehouses();
+ const { data: categoryTree } = useCategoryTree();
+ const { summary } = useStockCalculations(stockLevels);
+
+ // Flatten categories
+ const flatCategories = React.useMemo(() => {
+ const result: { id: number; name: string; level: number }[] = [];
+ const flatten = (cats: typeof categoryTree, level = 0) => {
+ if (!cats) return;
+ for (const cat of cats) {
+ result.push({ id: cat.id, name: cat.name, level });
+ if (cat.children) flatten(cat.children, level + 1);
+ }
+ };
+ flatten(categoryTree);
+ return result;
+ }, [categoryTree]);
+
+ // Filter by search
+ const filteredLevels = React.useMemo(() => {
+ if (!stockLevels) return [];
+ if (!search) return stockLevels;
+ const lower = search.toLowerCase();
+ return stockLevels.filter(
+ (l) =>
+ l.articleCode?.toLowerCase().includes(lower) ||
+ l.articleDescription?.toLowerCase().includes(lower),
+ );
+ }, [stockLevels, search]);
+
+ const columns: GridColDef[] = [
+ {
+ field: "articleCode",
+ headerName: "Codice",
+ width: 120,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {params.value}
+
+ ),
+ },
+ {
+ field: "articleDescription",
+ headerName: "Articolo",
+ flex: 1,
+ minWidth: 200,
+ },
+ {
+ field: "warehouseName",
+ headerName: "Magazzino",
+ width: 150,
+ },
+ {
+ field: "categoryName",
+ headerName: "Categoria",
+ width: 140,
+ },
+ {
+ field: "quantity",
+ headerName: "Giacenza",
+ width: 120,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) => {
+ const qty = params.value || 0;
+ const isLow = params.row.isLowStock;
+ return (
+
+ {isLow && }
+
+
+ );
+ },
+ },
+ {
+ field: "reservedQuantity",
+ headerName: "Riservata",
+ width: 100,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) =>
+ formatQuantity(params.value || 0),
+ },
+ {
+ field: "availableQuantity",
+ headerName: "Disponibile",
+ width: 110,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) => {
+ const available =
+ params.row.availableQuantity ||
+ params.row.quantity - params.row.reservedQuantity;
+ return (
+
+ {formatQuantity(available)}
+
+ );
+ },
+ },
+ {
+ field: "unitCost",
+ headerName: "Costo Medio",
+ width: 120,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) =>
+ formatCurrency(params.value || 0),
+ },
+ {
+ field: "stockValue",
+ headerName: "Valore",
+ width: 130,
+ align: "right",
+ renderCell: (params: GridRenderCellParams) => (
+
+ {formatCurrency(params.value || 0)}
+
+ ),
+ },
+ ];
+
+ if (error) {
+ return (
+
+ Errore: {(error as Error).message}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Giacenze di Magazzino
+
+ }
+ onClick={nav.goToValuation}
+ >
+ Valorizzazione
+
+
+
+ {/* Summary Cards */}
+
+
+
+
+
+ Articoli
+
+
+ {summary.articleCount}
+
+
+
+
+
+
+
+
+ Quantità Totale
+
+
+ {formatQuantity(summary.totalQuantity)}
+
+
+
+
+
+
+
+
+ Valore Totale
+
+
+ {formatCurrency(summary.totalValue)}
+
+
+
+
+
+
+
+
+ Sotto Scorta
+
+
+ {summary.lowStockCount + summary.outOfStockCount}
+
+
+
+
+
+
+ {/* Filters */}
+
+
+
+ setSearch(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: search && (
+
+ setSearch("")}>
+
+
+
+ ),
+ }}
+ />
+
+
+
+ Magazzino
+
+
+
+
+
+ Categoria
+
+
+
+
+ setLowStockOnly(e.target.checked)}
+ />
+ }
+ label="Solo sotto scorta"
+ />
+
+
+
+
+ {/* Data Grid */}
+
+ nav.goToArticle(params.row.articleId)}
+ disableRowSelectionOnClick
+ />
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/TransferMovementPage.tsx b/frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
new file mode 100644
index 0000000..61a1b05
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
@@ -0,0 +1,447 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ Grid,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Alert,
+ CircularProgress,
+ Autocomplete,
+ InputAdornment,
+ Divider,
+ Chip,
+} from "@mui/material";
+import {
+ ArrowBack as ArrowBackIcon,
+ Save as SaveIcon,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+ Check as ConfirmIcon,
+ SwapHoriz as TransferIcon,
+} from "@mui/icons-material";
+import { DatePicker } from "@mui/x-date-pickers/DatePicker";
+import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import dayjs, { Dayjs } from "dayjs";
+import "dayjs/locale/it";
+import {
+ useWarehouses,
+ useArticles,
+ useStockLevels,
+ useCreateTransferMovement,
+ useConfirmMovement,
+} from "../hooks";
+import { ArticleDto, CreateTransferDto, formatQuantity } from "../types";
+
+interface MovementLine {
+ id: string;
+ article: ArticleDto | null;
+ quantity: number;
+ notes?: string;
+ availableQty?: number;
+}
+
+export default function TransferMovementPage() {
+ const navigate = useNavigate();
+ const [movementDate, setMovementDate] = useState(dayjs());
+ const [sourceWarehouseId, setSourceWarehouseId] = useState("");
+ const [destWarehouseId, setDestWarehouseId] = useState("");
+ const [documentNumber, setDocumentNumber] = useState("");
+ const [externalReference, setExternalReference] = useState("");
+ const [notes, setNotes] = useState("");
+ const [lines, setLines] = useState([
+ { id: crypto.randomUUID(), article: null, quantity: 1 },
+ ]);
+ const [errors, setErrors] = useState>({});
+
+ const { data: warehouses } = useWarehouses({ active: true });
+ const { data: articles } = useArticles({ isActive: true });
+ const { data: stockLevels } = useStockLevels({
+ warehouseId: sourceWarehouseId || undefined,
+ });
+ const createMutation = useCreateTransferMovement();
+ const confirmMutation = useConfirmMovement();
+
+ const getAvailableQty = (articleId: number): number => {
+ if (!stockLevels) return 0;
+ const level = stockLevels.find((l) => l.articleId === articleId);
+ return level ? level.availableQuantity : 0;
+ };
+
+ const handleAddLine = () => {
+ setLines([
+ ...lines,
+ { id: crypto.randomUUID(), article: null, quantity: 1 },
+ ]);
+ };
+
+ const handleRemoveLine = (id: string) => {
+ if (lines.length > 1) {
+ setLines(lines.filter((l) => l.id !== id));
+ }
+ };
+
+ const handleLineChange = (
+ id: string,
+ field: keyof MovementLine,
+ value: unknown,
+ ) => {
+ setLines(
+ lines.map((l) => {
+ if (l.id === id) {
+ const updated = { ...l, [field]: value };
+ if (field === "article" && value) {
+ const article = value as ArticleDto;
+ updated.availableQty = getAvailableQty(article.id);
+ }
+ return updated;
+ }
+ return l;
+ }),
+ );
+ };
+
+ const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
+
+ const validate = (): boolean => {
+ const newErrors: Record = {};
+ if (!sourceWarehouseId) {
+ newErrors.sourceWarehouseId = "Seleziona magazzino origine";
+ }
+ if (!destWarehouseId) {
+ newErrors.destWarehouseId = "Seleziona magazzino destinazione";
+ }
+ if (
+ sourceWarehouseId &&
+ destWarehouseId &&
+ sourceWarehouseId === destWarehouseId
+ ) {
+ newErrors.destWarehouseId =
+ "Origine e destinazione devono essere diversi";
+ }
+ if (!movementDate) {
+ newErrors.movementDate = "Inserisci la data";
+ }
+ const validLines = lines.filter((l) => l.article && l.quantity > 0);
+ if (validLines.length === 0) {
+ newErrors.lines = "Inserisci almeno una riga";
+ }
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (andConfirm: boolean = false) => {
+ if (!validate()) return;
+
+ const data: CreateTransferDto = {
+ sourceWarehouseId: sourceWarehouseId as number,
+ destinationWarehouseId: destWarehouseId as number,
+ movementDate: movementDate!.format("YYYY-MM-DD"),
+ documentNumber: documentNumber || undefined,
+ externalReference: externalReference || undefined,
+ notes: notes || undefined,
+ lines: lines
+ .filter((l) => l.article && l.quantity > 0)
+ .map((l) => ({
+ articleId: l.article!.id,
+ quantity: l.quantity,
+ notes: l.notes,
+ })),
+ };
+
+ try {
+ const result = await createMutation.mutateAsync(data);
+ if (andConfirm) {
+ await confirmMutation.mutateAsync(result.id);
+ }
+ navigate(`/warehouse/movements/${result.id}`);
+ } catch (error) {
+ console.error("Errore:", error);
+ }
+ };
+
+ const isPending = createMutation.isPending || confirmMutation.isPending;
+
+ return (
+
+
+ {/* Header */}
+
+ navigate(-1)}>
+
+
+
+
+
+
+ Trasferimento tra Magazzini
+
+
+ Sposta merce da un magazzino all'altro
+
+
+
+
+
+ {(createMutation.error || confirmMutation.error) && (
+
+ Errore:{" "}
+ {((createMutation.error || confirmMutation.error) as Error).message}
+
+ )}
+
+ {/* Form */}
+
+
+ Dati Trasferimento
+
+
+
+
+
+
+
+ Magazzino Origine
+
+
+
+
+
+ Magazzino Destinazione
+
+ {errors.destWarehouseId && (
+
+ {errors.destWarehouseId}
+
+ )}
+
+
+
+ setDocumentNumber(e.target.value)}
+ />
+
+
+ setExternalReference(e.target.value)}
+ />
+
+
+ setNotes(e.target.value)}
+ />
+
+
+
+
+ {/* Lines */}
+
+
+ Articoli da Trasferire
+ } onClick={handleAddLine}>
+ Aggiungi
+
+
+
+ {errors.lines && (
+
+ {errors.lines}
+
+ )}
+
+
+
+
+
+ Articolo
+ Disponibile
+ Quantità
+ Note
+
+
+
+
+ {lines.map((line) => (
+
+
+
+ handleLineChange(line.id, "article", v)
+ }
+ options={articles || []}
+ getOptionLabel={(o) => `${o.code} - ${o.description}`}
+ renderInput={(params) => (
+
+ )}
+ isOptionEqualToValue={(o, v) => o.id === v.id}
+ />
+
+
+ {line.article && (
+
+ )}
+
+
+
+ handleLineChange(
+ line.id,
+ "quantity",
+ parseFloat(e.target.value) || 0,
+ )
+ }
+ slotProps={{
+ htmlInput: { min: 0, step: 0.01 },
+ input: {
+ endAdornment: line.article && (
+
+ {line.article.unitOfMeasure}
+
+ ),
+ },
+ }}
+ fullWidth
+ />
+
+
+
+ handleLineChange(line.id, "notes", e.target.value)
+ }
+ placeholder="Note"
+ fullWidth
+ />
+
+
+ handleRemoveLine(line.id)}
+ disabled={lines.length === 1}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ Totale: {formatQuantity(totalQuantity)}
+
+
+
+
+ {/* Actions */}
+
+
+ :
+ }
+ onClick={() => handleSubmit(false)}
+ disabled={isPending}
+ >
+ Salva Bozza
+
+ :
+ }
+ onClick={() => handleSubmit(true)}
+ disabled={isPending}
+ >
+ Salva e Conferma
+
+
+
+
+ );
+}
diff --git a/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx b/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
new file mode 100644
index 0000000..65947a1
--- /dev/null
+++ b/frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
@@ -0,0 +1,539 @@
+import React from "react";
+import {
+ Box,
+ Paper,
+ Typography,
+ Grid,
+ Card,
+ CardContent,
+ Button,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Chip,
+ Divider,
+ Skeleton,
+ Alert,
+} from "@mui/material";
+import {
+ Inventory as InventoryIcon,
+ Warehouse as WarehouseIcon,
+ TrendingUp as TrendingUpIcon,
+ TrendingDown as TrendingDownIcon,
+ Warning as WarningIcon,
+ Schedule as ScheduleIcon,
+ ArrowForward as ArrowForwardIcon,
+ Add as AddIcon,
+ Assessment as AssessmentIcon,
+} from "@mui/icons-material";
+import {
+ useArticles,
+ useWarehouses,
+ useMovements,
+ useStockLevels,
+ useExpiringBatches,
+} from "../hooks";
+import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
+import {
+ MovementStatus,
+ MovementType,
+ formatCurrency,
+ formatQuantity,
+ formatDate,
+ movementTypeLabels,
+ getMovementTypeColor,
+} from "../types";
+
+interface StatCardProps {
+ title: string;
+ value: string | number;
+ subtitle?: string;
+ icon: React.ReactNode;
+ color?: string;
+ loading?: boolean;
+}
+
+function StatCard({
+ title,
+ value,
+ subtitle,
+ icon,
+ color = "primary.main",
+ loading,
+}: StatCardProps) {
+ return (
+
+
+
+
+
+ {title}
+
+ {loading ? (
+
+ ) : (
+
+ {value}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {icon}
+
+
+
+
+ );
+}
+
+export default function WarehouseDashboard() {
+ const nav = useWarehouseNavigation();
+
+ const { data: articles, isLoading: loadingArticles } = useArticles({
+ isActive: true,
+ });
+ const { data: warehouses, isLoading: loadingWarehouses } = useWarehouses({
+ active: true,
+ });
+ const { data: stockLevels, isLoading: loadingStock } = useStockLevels();
+ const { data: recentMovements, isLoading: loadingMovements } = useMovements();
+ const { data: expiringBatches } = useExpiringBatches(30);
+
+ // Calculate statistics
+ const totalArticles = articles?.length || 0;
+ const totalWarehouses = warehouses?.length || 0;
+
+ const stockStats = React.useMemo(() => {
+ if (!stockLevels) return { totalValue: 0, lowStock: 0, outOfStock: 0 };
+
+ let totalValue = 0;
+ let lowStock = 0;
+ let outOfStock = 0;
+
+ for (const level of stockLevels) {
+ totalValue += level.stockValue || 0;
+ if (level.quantity <= 0) outOfStock++;
+ else if (level.isLowStock) lowStock++;
+ }
+
+ return { totalValue, lowStock, outOfStock };
+ }, [stockLevels]);
+
+ // Recent movements (last 10)
+ const lastMovements = React.useMemo(() => {
+ if (!recentMovements) return [];
+ return recentMovements
+ .filter((m) => m.status === MovementStatus.Confirmed)
+ .slice(0, 10);
+ }, [recentMovements]);
+
+ // Pending movements (drafts)
+ const pendingMovements = React.useMemo(() => {
+ if (!recentMovements) return [];
+ return recentMovements.filter((m) => m.status === MovementStatus.Draft);
+ }, [recentMovements]);
+
+ // Low stock articles
+ const lowStockArticles = React.useMemo(() => {
+ if (!stockLevels || !articles) return [];
+ const lowIds = new Set(
+ stockLevels
+ .filter((l) => l.isLowStock || l.quantity <= 0)
+ .map((l) => l.articleId),
+ );
+ return articles.filter((a) => lowIds.has(a.id)).slice(0, 5);
+ }, [stockLevels, articles]);
+
+ // Get stock quantity for an article
+ const getArticleStock = (articleId: number) => {
+ if (!stockLevels) return 0;
+ return stockLevels
+ .filter((l) => l.articleId === articleId)
+ .reduce((sum, l) => sum + l.quantity, 0);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Magazzino
+
+
+ Dashboard e panoramica giacenze
+
+
+
+ }
+ onClick={nav.goToNewInbound}
+ >
+ Nuovo Carico
+
+ }
+ onClick={nav.goToStockLevels}
+ >
+ Giacenze
+
+
+
+
+ {/* Stats Cards */}
+
+
+ }
+ loading={loadingArticles}
+ />
+
+
+ }
+ loading={loadingWarehouses}
+ />
+
+
+ }
+ color="success.main"
+ loading={loadingStock}
+ />
+
+
+ }
+ color="warning.main"
+ loading={loadingStock}
+ />
+
+
+
+ {/* Main Content */}
+
+ {/* Recent Movements */}
+
+
+
+ Ultimi Movimenti
+ }
+ onClick={nav.goToMovements}
+ >
+ Vedi tutti
+
+
+ {loadingMovements ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : lastMovements.length === 0 ? (
+
+ Nessun movimento recente
+
+ ) : (
+
+ {lastMovements.map((movement, index) => (
+
+ nav.goToMovement(movement.id)}
+ >
+
+ {movement.type === MovementType.Inbound ? (
+
+ ) : movement.type === MovementType.Outbound ? (
+
+ ) : (
+
+ )}
+
+
+
+ {movement.documentNumber || `MOV-${movement.id}`}
+
+
+
+ }
+ secondary={`${movement.sourceWarehouseName || movement.destinationWarehouseName || "-"} - ${formatDate(movement.movementDate)}`}
+ />
+
+ {formatQuantity(movement.lineCount)} righe
+
+