-
This commit is contained in:
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal file
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user