feat: Replace warehouse product groups with hierarchical categories and update related UI and API.

This commit is contained in:
2025-12-12 13:34:52 +01:00
parent 54cf1ff276
commit 08256f0019
18 changed files with 10153 additions and 2 deletions

View File

@@ -48,8 +48,15 @@ File riassuntivo dello stato di sviluppo di Zentral.
- [2025-12-06 01:35:00 - Fix Traduzione Tab Applicazioni](./devlog/2025-12-06-013500_fix_apps_tab_translation.md) - **Completato**
- Corretta chiave di traduzione errata per la tab "Gestione Applicazioni" e migliorata la gestione dell'aggiornamento etichette tab.
- [2025-12-06 Auto Codes Reorganization](./devlog/2025-12-06-021000_autocodes_reorg.md) - **Completato**
- [2025-12-12 Safety Training Schedule](./devlog/2025-12-12-105500_safety_training_schedule.md) - **Completato**
- [2025-12-12 Communications Module](./devlog/2025-12-12-110000_communications_module.md) - **Completato**
- [2025-12-12 Resend Integration](./devlog/2025-12-12-120000_resend_integration.md) - **Completato**
- [2025-12-12 Magazzino: Categorie Gerarchiche](./devlog/2025-12-12-133000_remove_product_groups_add_categories.md) - **Completato**
- Sostituita la logica "Gruppi Merceologici" con l'utilizzo esteso delle "Categorie Articoli" gerarchiche.
- Riorganizzazione UI Auto Codes, allineamento stile a Custom Fields, miglioramento traduzioni e categorizzazione.
- [2025-12-12 - Modulo Comunicazioni](./devlog/2025-12-12-110000_communications_module.md) - **In Corso**
- Implementazione invio email e gestione comunicazioni.
- [2025-12-12 - Gestione Modulo Formazione (Generale)](./devlog/2025-12-12-105500_safety_training_schedule.md) - **In Corso**
- Implementazione modulo formazione generale e scadenziario.
- Implementazione modulo formazione generale e scadenziario.
- [2025-12-12 - Implementazione Gruppi Merceologici Magazzino](./devlog/2025-12-12-125000_magazzino_gruppi_merceologici.md) - **In Corso**
- Implementazione gestione gruppi merceologici per il magazzino.

View File

@@ -0,0 +1,39 @@
# Implementazione Gruppi Merceologici Magazzino
## Richiesta
Implementare la gestione dei gruppi merceologici per la categorizzazione degli articoli nel modulo magazzino, sia backend che frontend.
## Stato Attuale
- Esiste già una gestione di "Categorie Articoli" (`WarehouseArticleCategory`) che è gerarchica.
- "Gruppi Merceologici" (`WarehouseProductGroup`) sarà una nuova entità, probabilmente una classificazione parallela non gerarchica (o piatta) spesso usata per fini statistici o contabili, o semplicemente come raggruppamento alternativo.
## Piano di Lavoro
### Backend
1. **Domain Layer**
- Creare entità `WarehouseProductGroup` in `Zentral.Domain.Entities.Warehouse`.
- Campi: Code, Name, Description, IsActive.
- Aggiornare `WarehouseArticle` aggiungendo FK `ProductGroupId` e navigation property.
2. **Infrastructure Layer**
- Aggiungere `DbSet<WarehouseProductGroup>` in `ApplicationDbContext`.
- Configurare le relazioni entity framework se necessario.
- Creare Migrazione `AddWarehouseProductGroups`.
3. **Service Layer**
- Aggiornare `IWarehouseService` e `WarehouseService` con i metodi CRUD per i gruppi merceologici.
4. **API Layer**
- Creare `WarehouseProductGroupsController`.
- Aggiornare DTOs degli articoli per includere `ProductGroupId`.
### Frontend
1. **Services**
- Creare `productGroupService.ts` per chiamare le API.
2. **Pages**
- Creare `ProductGroupsPage` per elenco e gestione (CRUD).
3. **Components**
- Aggiornare il form di creazione/modifica articolo per permettere la selezione del gruppo merceologico.
4. **Routing & Navigation**
- Aggiungere rotta per `ProductGroupsPage`.
- Aggiungere voce di menu nella sidebar del magazzino.
## Note
- L'implementazione seguirà lo stile esistente del modulo Warehouse, usando Services e Controllers.

View File

@@ -0,0 +1,34 @@
# Sostituzione Gruppi Merceologici con Categorie Gerarchiche
## Stato Corrente
IMPLEMENTATO
## Descrizione
Sostituita la gestione separata dei "Gruppi Merceologici" con l'utilizzo potenziato delle Categorie Articoli (`WarehouseArticleCategory`) già esistenti e gerarchiche.
## Modifiche Apportate
### Backend
- **Revert**: Rimossa entity `WarehouseProductGroup` e relativi controller e service.
- **Migration**: Creata e applicata migrazione `RemoveWarehouseProductGroups` per rimuovere la tabella dal database.
- **Services**: `WarehouseService` ripulito da logica `ProductGroups`.
### Frontend
- **Revert**: Rimossa pagina `ProductGroupsPage` e riferimenti nel codice.
- **New Feature**: Creata pagina `CategoriesPage` (`/warehouse/categories`) per gestire le categorie in modalità albero.
- Create
- Update
- Delete
- Struttura gerarchica visualizzata (Tree View).
- **Article Form**: Rimossa selezione "Gruppo Merceologico". La selezione della categoria utilizza `CategoryTree` appiattito per la selezione.
- **Navigation**: Aggiunto link "Categorie" nella sidebar del Magazzino.
## Note Tecniche
- La gestione delle categorie sfrutta la ricorsività supportata dall'entity `WarehouseArticleCategory`.
- L'interfaccia utente permette di gestire la gerarchia creando categorie "root" o sottocategorie.
## Verifica
- **Backend API**:
- `GET /api/warehouse/categories` -> Disponibile.
- `GET /api/warehouse/categories/tree` -> Disponibile (ritorna JSON corretto).
- `GET /api/warehouse/product-groups` -> **404 Not Found** (Correttamente rimosso).

View File

@@ -28,6 +28,10 @@ public interface IWarehouseService
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
Task DeleteCategoryAsync(int id);
// ===============================================
// GRUPPI MERCEOLOGICI
// ===============================================
// ===============================================
// MAGAZZINI
// ===============================================

View File

@@ -60,6 +60,7 @@ public class WarehouseService : IWarehouseService
if (filter.CategoryId.HasValue)
query = query.Where(a => a.CategoryId == filter.CategoryId);
if (filter.IsActive.HasValue)
query = query.Where(a => a.IsActive == filter.IsActive);
@@ -336,6 +337,7 @@ public class WarehouseService : IWarehouseService
#endregion
#region Magazzini
public async Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false)

View File

@@ -40,6 +40,11 @@ public class WarehouseArticle : BaseEntity
/// </summary>
public int? CategoryId { get; set; }
/// <summary>
/// Gruppo merceologico
/// </summary>
public int? ProductGroupId { get; set; }
/// <summary>
/// Unità di misura principale (es. PZ, KG, LT, MT)
/// </summary>

View File

@@ -445,6 +445,7 @@ public class ZentralDbContext : DbContext
.WithMany(c => c.Articles)
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
});
// ArticleBatch

View File

@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddWarehouseProductGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ProductGroupId",
table: "WarehouseArticles",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "WarehouseProductGroups",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Code = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WarehouseArticles_ProductGroupId",
table: "WarehouseArticles",
column: "ProductGroupId");
migrationBuilder.CreateIndex(
name: "IX_WarehouseProductGroups_Code",
table: "WarehouseProductGroups",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WarehouseProductGroups_IsActive",
table: "WarehouseProductGroups",
column: "IsActive");
migrationBuilder.AddForeignKey(
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
table: "WarehouseArticles",
column: "ProductGroupId",
principalTable: "WarehouseProductGroups",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
table: "WarehouseArticles");
migrationBuilder.DropTable(
name: "WarehouseProductGroups");
migrationBuilder.DropIndex(
name: "IX_WarehouseArticles_ProductGroupId",
table: "WarehouseArticles");
migrationBuilder.DropColumn(
name: "ProductGroupId",
table: "WarehouseArticles");
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Zentral.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class RemoveWarehouseProductGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
table: "WarehouseArticles");
migrationBuilder.DropTable(
name: "WarehouseProductGroups");
migrationBuilder.DropIndex(
name: "IX_WarehouseArticles_ProductGroupId",
table: "WarehouseArticles");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WarehouseProductGroups",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Code = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Notes = table.Column<string>(type: "TEXT", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WarehouseProductGroups", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_WarehouseArticles_ProductGroupId",
table: "WarehouseArticles",
column: "ProductGroupId");
migrationBuilder.CreateIndex(
name: "IX_WarehouseProductGroups_Code",
table: "WarehouseProductGroups",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WarehouseProductGroups_IsActive",
table: "WarehouseProductGroups",
column: "IsActive");
migrationBuilder.AddForeignKey(
name: "FK_WarehouseArticles_WarehouseProductGroups_ProductGroupId",
table: "WarehouseArticles",
column: "ProductGroupId",
principalTable: "WarehouseProductGroups",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
}
}

View File

@@ -3675,6 +3675,9 @@ namespace Zentral.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int?>("ProductGroupId")
.HasColumnType("INTEGER");
b.Property<decimal?>("ReorderPoint")
.HasPrecision(18, 4)
.HasColumnType("TEXT");

View File

@@ -0,0 +1,304 @@
import React, { useState } from 'react';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
Switch,
FormControlLabel,
Collapse,
List,
ListItem,
ListItemText,
ListItemIcon,
Paper,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Folder as FolderIcon,
ExpandMore as ExpandMoreIcon,
KeyboardArrowRight as KeyboardArrowRightIcon,
} from '@mui/icons-material';
import { useCategoryTree, useCreateCategory, useUpdateCategory, useDeleteCategory } from '../hooks';
import { CategoryTreeDto, CreateCategoryDto, UpdateCategoryDto } from '../types';
interface CategoryItemProps {
category: CategoryTreeDto;
onEdit: (category: CategoryTreeDto) => void;
onDelete: (id: number) => void;
onAddSubCategory: (parentId: number) => void;
}
const CategoryItem: React.FC<CategoryItemProps> = ({ category, onEdit, onDelete, onAddSubCategory }) => {
const [open, setOpen] = useState(true);
const hasChildren = category.children && category.children.length > 0;
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(!open);
};
return (
<>
<ListItem
sx={{
pl: category.level * 4,
borderBottom: '1px solid #eee',
'&:hover': { bgcolor: 'action.hover' },
}}
secondaryAction={
<Box>
<IconButton size="small" onClick={() => onAddSubCategory(category.id)} title="Aggiungi sottocategoria">
<AddIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => onEdit(category)} title="Modifica">
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => onDelete(category.id)} title="Elimina" color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemIcon sx={{ minWidth: 40, cursor: hasChildren ? 'pointer' : 'default' }} onClick={(e) => hasChildren && handleToggle(e)}>
{hasChildren ? (open ? <ExpandMoreIcon /> : <KeyboardArrowRightIcon />) : <Box sx={{ width: 24 }} />}
</ListItemIcon>
<ListItemIcon>
<FolderIcon color={category.isActive ? 'primary' : 'disabled'} />
</ListItemIcon>
<ListItemText
primary={
<Typography variant="body1" fontWeight="medium">
{category.name}
</Typography>
}
secondary={category.description}
/>
</ListItem>
{hasChildren && (
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{category.children.map((child) => (
<CategoryItem
key={child.id}
category={child}
onEdit={onEdit}
onDelete={onDelete}
onAddSubCategory={onAddSubCategory}
/>
))}
</List>
</Collapse>
)}
</>
);
};
export default function CategoriesPage() {
const { data: categories, isLoading } = useCategoryTree();
const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory();
const deleteMutation = useDeleteCategory();
const [openDialog, setOpenDialog] = useState(false);
const [editingCategory, setEditingCategory] = useState<CategoryTreeDto | null>(null);
const [parentCategoryId, setParentCategoryId] = useState<number | undefined>(undefined);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<number | null>(null);
const [formData, setFormData] = useState<CreateCategoryDto>({
name: '',
description: '',
sortOrder: 0,
parentCategoryId: undefined,
});
const [isActive, setIsActive] = useState(true);
const handleOpenDialog = (category?: CategoryTreeDto, parentId?: number) => {
if (category) {
setEditingCategory(category);
setFormData({
name: category.name,
description: category.description || '',
sortOrder: 0, // Not in TreeDto usually, default to 0
parentCategoryId: undefined, // Usually handled by structure
});
setIsActive(category.isActive);
setParentCategoryId(undefined);
} else {
setEditingCategory(null);
setFormData({
name: '',
description: '',
sortOrder: 0,
parentCategoryId: parentId,
});
setIsActive(true);
setParentCategoryId(parentId);
}
setOpenDialog(true);
};
const handleCloseDialog = () => {
setOpenDialog(false);
setEditingCategory(null);
setParentCategoryId(undefined);
};
const handleSubmit = async () => {
try {
if (editingCategory) {
const updateData: UpdateCategoryDto = {
name: formData.name,
description: formData.description || undefined,
sortOrder: formData.sortOrder,
isActive: isActive,
};
await updateMutation.mutateAsync({ id: editingCategory.id, data: updateData });
} else {
const createData: CreateCategoryDto = {
name: formData.name,
description: formData.description || undefined,
sortOrder: formData.sortOrder,
parentCategoryId: parentCategoryId,
};
await createMutation.mutateAsync(createData);
}
handleCloseDialog();
} catch (error) {
console.error("Error saving category:", error);
}
};
const handleDeleteClick = (id: number) => {
setCategoryToDelete(id);
setDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (categoryToDelete) {
try {
await deleteMutation.mutateAsync(categoryToDelete);
setDeleteConfirmOpen(false);
setCategoryToDelete(null);
} catch (error) {
console.error("Error deleting category:", error);
}
}
};
if (isLoading) {
return <Typography>Caricamento...</Typography>;
}
return (
<Box>
<Box sx={{ mb: 3, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h5" fontWeight="bold">
Categorie Articoli
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Nuova Categoria Root
</Button>
</Box>
<Paper elevation={0} variant="outlined">
<List>
{categories?.map((category) => (
<CategoryItem
key={category.id}
category={category}
onEdit={handleOpenDialog}
onDelete={handleDeleteClick}
onAddSubCategory={(parentId) => handleOpenDialog(undefined, parentId)}
/>
))}
{(!categories || categories.length === 0) && (
<ListItem>
<ListItemText primary="Nessuna categoria trovata" />
</ListItem>
)}
</List>
</Paper>
{/* Create/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingCategory ? "Modifica Categoria" : "Nuova Categoria"}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Nome"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
fullWidth
required
/>
<TextField
label="Descrizione"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
fullWidth
multiline
rows={3}
/>
<TextField
label="Ordinamento"
type="number"
value={formData.sortOrder}
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
fullWidth
/>
{editingCategory && (
<FormControlLabel
control={
<Switch
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
}
label="Attivo"
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button>
<Button onClick={handleSubmit} variant="contained" disabled={!formData.name}>
Salva
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)}>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare questa categoria? L'operazione non può essere annullata.
Se la categoria contiene sottocategorie o articoli, potrebbe non essere possibile eliminarla.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmOpen(false)}>Annulla</Button>
<Button onClick={handleConfirmDelete} color="error" variant="contained">
Elimina
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -4,6 +4,7 @@ export { default as WarehouseDashboard } from './WarehouseDashboard';
// Articles
export { default as ArticlesPage } from './ArticlesPage';
export { default as ArticleFormPage } from './ArticleFormPage';
export { default as CategoriesPage } from './CategoriesPage';
// Warehouse Locations
export { default as WarehouseLocationsPage } from './WarehouseLocationsPage';

View File

@@ -4,6 +4,7 @@ import {
WarehouseDashboard,
ArticlesPage,
ArticleFormPage,
CategoriesPage,
WarehouseLocationsPage,
MovementsPage,
InboundMovementPage,
@@ -30,6 +31,7 @@ export default function WarehouseRoutes() {
<Route path="articles/new" element={<ArticleFormPage />} />
<Route path="articles/:id" element={<ArticleFormPage />} />
<Route path="articles/:id/edit" element={<ArticleFormPage />} />
<Route path="categories" element={<CategoriesPage />} />
{/* Warehouse Locations */}
<Route path="locations" element={<WarehouseLocationsPage />} />

View File

@@ -37,7 +37,6 @@ import {
UpdateArticleDto,
UpdateBatchDto,
UpdateCategoryDto,
UpdateWarehouseDto,
ValuationMethod,
WarehouseLocationDto,
} from "../types";
@@ -558,6 +557,7 @@ export const inventoryService = {
},
};
// Export all services
export default {
locations: warehouseLocationService,

View File

@@ -230,6 +230,7 @@ export interface UpdateCategoryDto {
notes?: string;
}
// ===============================================
// ARTICLE
// ===============================================

View File

@@ -36,6 +36,7 @@ import {
Timeline as TimelineIcon,
PrecisionManufacturing as ManufacturingIcon,
Category as CategoryIcon,
Folder as FolderIcon,
AttachMoney as AttachMoneyIcon,
Receipt as ReceiptIcon,
ChevronLeft,
@@ -116,6 +117,7 @@ export default function Sidebar({ onClose, isCollapsed = false, onToggleCollapse
children: [
{ id: 'wh-dashboard', label: t('menu.dashboard'), tabLabel: t('menu.warehouse'), icon: <DashboardIcon />, path: '/warehouse', translationKey: 'menu.warehouse' },
{ id: 'wh-articles', label: t('menu.articles'), icon: <CategoryIcon />, path: '/warehouse/articles', translationKey: 'menu.articles' },
{ id: 'wh-categories', label: t('menu.categories'), icon: <FolderIcon />, path: '/warehouse/categories', translationKey: 'menu.categories' },
{ id: 'wh-locations', label: t('menu.location'), icon: <PlaceIcon />, path: '/warehouse/locations', translationKey: 'menu.location' },
{ id: 'wh-movements', label: t('menu.movements'), icon: <SwapIcon />, path: '/warehouse/movements', translationKey: 'menu.movements' },
{ id: 'wh-stock', label: t('menu.stock'), icon: <StorageIcon />, path: '/warehouse/stock', translationKey: 'menu.stock' },