-
This commit is contained in:
@@ -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
|
||||
|
||||
101
frontend/src/modules/warehouse/components/WarehouseLayout.tsx
Normal file
101
frontend/src/modules/warehouse/components/WarehouseLayout.tsx
Normal file
@@ -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: <DashboardIcon fontSize="small" /> },
|
||||
{ label: "Articoli", path: "/warehouse/articles", icon: <ArticleIcon fontSize="small" /> },
|
||||
{ label: "Magazzini", path: "/warehouse/locations", icon: <LocationIcon fontSize="small" /> },
|
||||
{ label: "Movimenti", path: "/warehouse/movements", icon: <MovementIcon fontSize="small" /> },
|
||||
{ label: "Giacenze", path: "/warehouse/stock", icon: <StockIcon fontSize="small" /> },
|
||||
{ label: "Inventario", path: "/warehouse/inventory", icon: <InventoryIcon fontSize="small" /> },
|
||||
];
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: 2 }}>
|
||||
{/* Header & Navigation */}
|
||||
<Paper sx={{ px: 2, pt: 2, pb: 0 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" component="h1" fontWeight="bold" gutterBottom>
|
||||
Gestione Magazzino
|
||||
</Typography>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
<Link underline="hover" color="inherit" href="/">
|
||||
Home
|
||||
</Link>
|
||||
<Typography color="text.primary">Magazzino</Typography>
|
||||
{navItems[value]?.label !== "Dashboard" && (
|
||||
<Typography color="text.primary">{navItems[value]?.label}</Typography>
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="warehouse navigation tabs"
|
||||
sx={{ borderBottom: 1, borderColor: "divider" }}
|
||||
>
|
||||
{navItems.map((item, index) => (
|
||||
<Tab
|
||||
key={item.path}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
iconPosition="start"
|
||||
id={`warehouse-tab-${index}`}
|
||||
aria-controls={`warehouse-tabpanel-${index}`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
333
frontend/src/modules/warehouse/pages/InventoryCountPage.tsx
Normal file
333
frontend/src/modules/warehouse/pages/InventoryCountPage.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Typography
|
||||
color={val === 0 ? "text.primary" : val < 0 ? "error" : "success"}
|
||||
fontWeight={val !== 0 ? "bold" : "normal"}
|
||||
>
|
||||
{val > 0 ? "+" : ""}
|
||||
{val.toFixed(2)}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate("/warehouse/inventory")}
|
||||
>
|
||||
Indietro
|
||||
</Button>
|
||||
<Typography variant="h4">
|
||||
Inventario: {inventory.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={inventory.status}
|
||||
color={
|
||||
inventory.status === InventoryStatus.Confirmed
|
||||
? "success"
|
||||
: inventory.status === InventoryStatus.InProgress
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{inventory.status === InventoryStatus.Draft && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<StartIcon />}
|
||||
onClick={() => startMutation.mutate()}
|
||||
>
|
||||
Avvia Inventario
|
||||
</Button>
|
||||
)}
|
||||
{inventory.status === InventoryStatus.InProgress && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<CompleteIcon />}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
Completa Conteggio
|
||||
</Button>
|
||||
)}
|
||||
{inventory.status === InventoryStatus.Completed && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<ConfirmIcon />}
|
||||
onClick={() => setConfirmDialogOpen(true)}
|
||||
>
|
||||
Conferma e Rettifica
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Data Inventario
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{dayjs(inventory.inventoryDate).format("DD/MM/YYYY")}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Magazzino
|
||||
</Typography>
|
||||
<Typography variant="h6">{inventory.warehouseName}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Righe Totali
|
||||
</Typography>
|
||||
<Typography variant="h6">{inventory.lineCount}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="text.secondary" gutterBottom>
|
||||
Righe Contate
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{inventory.lines.filter((l) => l.countedQuantity !== null).length}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{inventory.status === InventoryStatus.Completed && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
L'inventario è completato. Verifica le differenze prima di confermare.
|
||||
La conferma genererà automaticamente i movimenti di rettifica.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={inventory.lines}
|
||||
columns={columns}
|
||||
processRowUpdate={handleProcessRowUpdate}
|
||||
onProcessRowUpdateError={(error) => console.error(error)}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
sx={{
|
||||
"& .editable-cell": {
|
||||
backgroundColor: "#f0f8ff",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog
|
||||
open={confirmDialogOpen}
|
||||
onClose={() => setConfirmDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Conferma Inventario</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Sei sicuro di voler confermare l'inventario? Questa operazione è
|
||||
irreversibile e genererà i movimenti di rettifica per le differenze
|
||||
riscontrate.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialogOpen(false)}>Annulla</Button>
|
||||
<Button
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
color="success"
|
||||
variant="contained"
|
||||
autoFocus
|
||||
>
|
||||
Conferma
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
258
frontend/src/modules/warehouse/pages/InventoryFormPage.tsx
Normal file
258
frontend/src/modules/warehouse/pages/InventoryFormPage.tsx
Normal file
@@ -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<CreateInventoryCountDto>({
|
||||
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 (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link
|
||||
color="inherit"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/warehouse/inventory");
|
||||
}}
|
||||
>
|
||||
Inventari
|
||||
</Link>
|
||||
<Typography color="text.primary">
|
||||
{isEditing ? "Modifica Inventario" : "Nuovo Inventario"}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">
|
||||
{isEditing ? `Inventario ${inventory?.code}` : "Nuovo Inventario"}
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate("/warehouse/inventory")}
|
||||
>
|
||||
Indietro
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
label="Descrizione"
|
||||
fullWidth
|
||||
required
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
label="Data Inventario"
|
||||
type="date"
|
||||
fullWidth
|
||||
required
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={formData.inventoryDate}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, inventoryDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Magazzino</InputLabel>
|
||||
<Select
|
||||
value={formData.warehouseId || ""}
|
||||
label="Magazzino"
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
warehouseId: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
disabled={isEditing} // Cannot change warehouse after creation usually
|
||||
>
|
||||
{warehouses.map((w) => (
|
||||
<MenuItem key={w.id} value={w.id}>
|
||||
{w.name} ({w.code})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Categoria (Opzionale)</InputLabel>
|
||||
<Select
|
||||
value={formData.categoryId || ""}
|
||||
label="Categoria (Opzionale)"
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
categoryId: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Tutte</em>
|
||||
</MenuItem>
|
||||
{categories.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Tipo Inventario</InputLabel>
|
||||
<Select
|
||||
value={formData.type}
|
||||
label="Tipo Inventario"
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: Number(e.target.value) as InventoryType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value={InventoryType.Full}>Completo</MenuItem>
|
||||
<MenuItem value={InventoryType.Partial}>Parziale</MenuItem>
|
||||
<MenuItem value={InventoryType.Cyclic}>Ciclico</MenuItem>
|
||||
<MenuItem value={InventoryType.Sample}>A Campione</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<TextField
|
||||
label="Note"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={formData.notes || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notes: e.target.value })
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{isEditing ? "Salva Modifiche" : "Crea e Inizia"}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
204
frontend/src/modules/warehouse/pages/InventoryListPage.tsx
Normal file
204
frontend/src/modules/warehouse/pages/InventoryListPage.tsx
Normal file
@@ -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<InventoryStatus | undefined>(
|
||||
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 <Chip label="Bozza" size="small" />;
|
||||
case InventoryStatus.InProgress:
|
||||
return <Chip label="In Corso" color="primary" size="small" />;
|
||||
case InventoryStatus.Completed:
|
||||
return <Chip label="Completato" color="info" size="small" />;
|
||||
case InventoryStatus.Confirmed:
|
||||
return <Chip label="Confermato" color="success" size="small" />;
|
||||
case InventoryStatus.Cancelled:
|
||||
return <Chip label="Annullato" color="error" size="small" />;
|
||||
default:
|
||||
return <Chip label={status} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
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<InventoryCountDto>) =>
|
||||
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<InventoryCountDto>) => (
|
||||
<Box>
|
||||
<Tooltip title="Dettaglio">
|
||||
<IconButton size="small" onClick={() => handleView(params.row.id)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{params.row.status === InventoryStatus.Draft && (
|
||||
<Tooltip title="Avvia Conteggio">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleStart(params.row.id)}
|
||||
>
|
||||
<StartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{params.row.status === InventoryStatus.InProgress && (
|
||||
<Tooltip title="Continua Conteggio">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleStart(params.row.id)}
|
||||
>
|
||||
<StartIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{params.row.status === InventoryStatus.Draft && (
|
||||
<Tooltip title="Annulla">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
if (confirm("Sei sicuro di voler annullare questo inventario?")) {
|
||||
cancelMutation.mutate(params.row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">Inventari Fisici</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Nuovo Inventario
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={inventories}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
sorting: {
|
||||
sortModel: [{ field: "inventoryDate", sort: "desc" }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -174,23 +174,16 @@ export default function WarehouseDashboard() {
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
{/* Actions */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4" fontWeight="bold">
|
||||
Magazzino
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Dashboard e panoramica giacenze
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
@@ -206,7 +199,6 @@ export default function WarehouseDashboard() {
|
||||
Giacenze
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,12 +10,18 @@ import {
|
||||
OutboundMovementPage,
|
||||
TransferMovementPage,
|
||||
StockLevelsPage,
|
||||
InventoryListPage,
|
||||
InventoryFormPage,
|
||||
InventoryCountPage,
|
||||
} from "./pages";
|
||||
|
||||
import WarehouseLayout from "./components/WarehouseLayout";
|
||||
|
||||
export default function WarehouseRoutes() {
|
||||
return (
|
||||
<WarehouseProvider>
|
||||
<Routes>
|
||||
<Route element={<WarehouseLayout />}>
|
||||
{/* Dashboard */}
|
||||
<Route index element={<WarehouseDashboard />} />
|
||||
|
||||
@@ -44,8 +50,15 @@ export default function WarehouseRoutes() {
|
||||
{/* Stock */}
|
||||
<Route path="stock" element={<StockLevelsPage />} />
|
||||
|
||||
{/* Inventory */}
|
||||
<Route path="inventory" element={<InventoryListPage />} />
|
||||
<Route path="inventory/new" element={<InventoryFormPage />} />
|
||||
<Route path="inventory/:id" element={<InventoryFormPage />} />
|
||||
<Route path="inventory/:id/count" element={<InventoryCountPage />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<WarehouseDashboard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</WarehouseProvider>
|
||||
);
|
||||
|
||||
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
Binary file not shown.
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user