524 lines
15 KiB
TypeScript
524 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|