This commit is contained in:
2025-11-29 14:52:39 +01:00
parent bb2d0729e1
commit c7dbcde5dd
49 changed files with 23088 additions and 5 deletions

View File

@@ -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<ViewMode>("list");
const [search, setSearch] = useState("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [showInactive, setShowInactive] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [articleToDelete, setArticleToDelete] = useState<ArticleDto | null>(
null,
);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuArticle, setMenuArticle] = useState<ArticleDto | null>(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<HTMLElement>,
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<ArticleDto>) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
{params.row.hasImage ? (
<img
src={`/api/warehouse/articles/${params.row.id}/image`}
alt={params.row.description}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<ImageIcon sx={{ color: "grey.400" }} />
)}
</Box>
),
},
{
field: "code",
headerName: "Codice",
width: 120,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
),
},
{
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<ArticleDto>) =>
formatCurrency(params.value || 0),
},
{
field: "isActive",
headerName: "Stato",
width: 100,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Chip
label={params.value ? "Attivo" : "Inattivo"}
size="small"
color={params.value ? "success" : "default"}
/>
),
},
{
field: "actions",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<IconButton size="small" onClick={(e) => handleMenuOpen(e, params.row)}>
<MoreVertIcon />
</IconButton>
),
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento degli articoli: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Anagrafica Articoli
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={nav.goToNewArticle}
>
Nuovo Articolo
</Button>
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca per codice o descrizione..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Categoria</InputLabel>
<Select
value={categoryId}
label="Categoria"
onChange={(e) => setCategoryId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutte</em>
</MenuItem>
{flatCategories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{"—".repeat(cat.level)} {cat.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Button
variant={showInactive ? "contained" : "outlined"}
size="small"
startIcon={<FilterListIcon />}
onClick={() => setShowInactive(!showInactive)}
fullWidth
>
{showInactive ? "Mostra Tutti" : "Solo Attivi"}
</Button>
</Grid>
<Grid
size={{ xs: 12, sm: 6, md: 3 }}
sx={{ display: "flex", justifyContent: "flex-end" }}
>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, value) => value && setViewMode(value)}
size="small"
>
<ToggleButton value="list">
<Tooltip title="Vista Lista">
<ViewListIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="grid">
<Tooltip title="Vista Griglia">
<ViewModuleIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Paper>
{/* Content */}
{viewMode === "list" ? (
<Paper sx={{ height: 600 }}>
<DataGrid
rows={articles || []}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "code", sort: "asc" }] },
}}
onRowDoubleClick={(params) => nav.goToArticle(params.row.id)}
disableRowSelectionOnClick
sx={{
"& .MuiDataGrid-row:hover": {
cursor: "pointer",
},
}}
/>
</Paper>
) : (
<Grid container spacing={2}>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card>
<Skeleton variant="rectangular" height={140} />
<CardContent>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" />
</CardContent>
</Card>
</Grid>
))
) : articles?.length === 0 ? (
<Grid size={12}>
<Paper sx={{ p: 4, textAlign: "center" }}>
<InventoryIcon
sx={{ fontSize: 48, color: "grey.400", mb: 2 }}
/>
<Typography color="text.secondary">
Nessun articolo trovato
</Typography>
</Paper>
</Grid>
) : (
articles?.map((article) => (
<Grid key={article.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
cursor: "pointer",
"&:hover": { boxShadow: 4 },
}}
onClick={() => nav.goToArticle(article.id)}
>
{article.hasImage ? (
<CardMedia
component="img"
height="140"
image={`/api/warehouse/articles/${article.id}/image`}
alt={article.description}
sx={{ objectFit: "cover" }}
/>
) : (
<Box
sx={{
height: 140,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
<ImageIcon sx={{ fontSize: 48, color: "grey.400" }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="caption" color="text.secondary">
{article.code}
</Typography>
<Typography
variant="subtitle1"
fontWeight="medium"
gutterBottom
noWrap
>
{article.description}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{article.categoryName && (
<Chip label={article.categoryName} size="small" />
)}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
nav.goToEditArticle(article.id);
}}
>
Modifica
</Button>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, article)}
sx={{ ml: "auto" }}
>
<MoreVertIcon />
</IconButton>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
)}
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleEdit}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Modifica</ListItemText>
</MenuItem>
<MenuItem onClick={handleViewStock}>
<ListItemIcon>
<InventoryIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Visualizza Giacenze</ListItemText>
</MenuItem>
<MenuItem onClick={handleDeleteClick} sx={{ color: "error.main" }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Elimina</ListItemText>
</MenuItem>
</Menu>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare l'articolo{" "}
<strong>
{articleToDelete?.code} - {articleToDelete?.description}
</strong>
?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}