feat: Introduce dynamic tab management, sidebar, and search bar components, update backend database schema, and remove old SQL schema.
This commit is contained in:
@@ -6,3 +6,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
|||||||
|
|
||||||
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
- [2025-12-02 Rebranding Apollinare to Zentral](./log/2025-12-02_rebranding.md) - **Completato**
|
||||||
- Rinomina completa del progetto (Backend & Frontend).
|
- Rinomina completa del progetto (Backend & Frontend).
|
||||||
|
- [2025-12-03 UI Restructuring](./devlog/2025-12-03_ui_restructuring.md) - **Completato**
|
||||||
|
- Ristrutturazione interfaccia: Sidebar a 2 livelli, Tabs, SearchBar.
|
||||||
|
- [2025-12-03 Backend Fix](./devlog/2025-12-03_backend_fix.md) - **Completato**
|
||||||
|
- Fix mancata migrazione database e avvio backend.
|
||||||
|
|||||||
17
docs/development/devlog/2025-12-03_backend_fix.md
Normal file
17
docs/development/devlog/2025-12-03_backend_fix.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Backend Fix - Sync Model Changes
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
The backend was failing to start with a `System.InvalidOperationException` because the `ZentralDbContext` model had pending changes that were not captured in a migration.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
1. Created a new migration `SyncModelChanges` to synchronize the database schema with the code.
|
||||||
|
- Command: `dotnet ef migrations add SyncModelChanges --project Zentral.Infrastructure --startup-project Zentral.API`
|
||||||
|
2. Updated the database.
|
||||||
|
- Command: `dotnet ef database update --project Zentral.Infrastructure --startup-project Zentral.API`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- [x] Create Migration
|
||||||
|
- [x] Update Database
|
||||||
|
- [x] Verify Backend Startup
|
||||||
|
|
||||||
|
The backend now starts correctly and listens on the configured port.
|
||||||
53
docs/development/devlog/2025-12-03_ui_restructuring.md
Normal file
53
docs/development/devlog/2025-12-03_ui_restructuring.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# UI Restructuring Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Restructure the graphical interface according to the guidelines:
|
||||||
|
- 2-level sidebar (Apps -> Functions).
|
||||||
|
- Top search bar.
|
||||||
|
- Tabbed interface for the main viewport.
|
||||||
|
- Responsive design.
|
||||||
|
|
||||||
|
## Components to Create/Update
|
||||||
|
|
||||||
|
### 1. TabContext (`src/frontend/src/contexts/TabContext.tsx`)
|
||||||
|
- **State**:
|
||||||
|
- `tabs`: Array of `{ id: string, path: string, label: string, icon?: ReactNode }`.
|
||||||
|
- `activeTabPath`: string.
|
||||||
|
- **Actions**:
|
||||||
|
- `openTab(tab)`: Adds tab if not exists, sets as active.
|
||||||
|
- `closeTab(path)`: Removes tab. If active, switches to neighbor.
|
||||||
|
- `setActiveTab(path)`: Updates active tab.
|
||||||
|
- **Persistence**: Save `tabs` and `activeTabPath` to `localStorage`.
|
||||||
|
|
||||||
|
### 2. TabsBar (`src/frontend/src/components/TabsBar.tsx`)
|
||||||
|
- Renders the list of open tabs using MUI `Tabs` or a custom horizontal list.
|
||||||
|
- Handles click (navigate) and close actions.
|
||||||
|
- Mobile: Show as a popup or scrollable bar.
|
||||||
|
|
||||||
|
### 3. Sidebar (`src/frontend/src/components/Sidebar.tsx`)
|
||||||
|
- Refactor existing Drawer content.
|
||||||
|
- Implement nested lists (Accordion style) for Modules.
|
||||||
|
- Structure:
|
||||||
|
- **Core**: Dashboard, Calendar, Events, Clients, Location, Articles, Resources.
|
||||||
|
- **Warehouse**: Dashboard, Articles, Locations, Movements, Stock, Inventory.
|
||||||
|
- **Purchases**: Suppliers, Orders.
|
||||||
|
- **Sales**: Orders.
|
||||||
|
- **Production**: Dashboard, Orders, BOM, Work Centers, Cycles, MRP.
|
||||||
|
- **Admin**: Modules, Auto Codes, Custom Fields.
|
||||||
|
|
||||||
|
### 4. SearchBar (`src/frontend/src/components/SearchBar.tsx`)
|
||||||
|
- Input field in AppBar.
|
||||||
|
- Filters available menu items.
|
||||||
|
- On selection, opens the corresponding tab.
|
||||||
|
|
||||||
|
### 5. Layout (`src/frontend/src/components/Layout.tsx`)
|
||||||
|
- Integrate `Sidebar`, `SearchBar`, and `TabsBar`.
|
||||||
|
- Ensure responsive behavior.
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
1. Create `TabContext`.
|
||||||
|
2. Create `TabsBar`.
|
||||||
|
3. Refactor `Layout` to include `TabsBar` and use `TabContext`.
|
||||||
|
4. Create `Sidebar` with nested structure.
|
||||||
|
5. Create `SearchBar` and integrate into `Layout`.
|
||||||
|
6. Verify functionality.
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- APOLLINARE WAREHOUSE MODULE - DATABASE TABLES
|
|
||||||
-- SQLite
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Magazzini
|
|
||||||
CREATE TABLE IF NOT EXISTS WarehouseLocations (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
Code TEXT NOT NULL UNIQUE,
|
|
||||||
Name TEXT NOT NULL,
|
|
||||||
Description TEXT,
|
|
||||||
Address TEXT,
|
|
||||||
City TEXT,
|
|
||||||
Province TEXT,
|
|
||||||
PostalCode TEXT,
|
|
||||||
Country TEXT DEFAULT 'Italia',
|
|
||||||
Type INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsDefault INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_Code ON WarehouseLocations(Code);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_IsDefault ON WarehouseLocations(IsDefault);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_IsActive ON WarehouseLocations(IsActive);
|
|
||||||
|
|
||||||
-- Categorie Articoli
|
|
||||||
CREATE TABLE IF NOT EXISTS WarehouseArticleCategories (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
Code TEXT NOT NULL UNIQUE,
|
|
||||||
Name TEXT NOT NULL,
|
|
||||||
Description TEXT,
|
|
||||||
ParentCategoryId INTEGER,
|
|
||||||
Level INTEGER NOT NULL DEFAULT 0,
|
|
||||||
FullPath TEXT,
|
|
||||||
Icon TEXT,
|
|
||||||
Color TEXT,
|
|
||||||
DefaultValuationMethod INTEGER,
|
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ParentCategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_Code ON WarehouseArticleCategories(Code);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_ParentCategoryId ON WarehouseArticleCategories(ParentCategoryId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_FullPath ON WarehouseArticleCategories(FullPath);
|
|
||||||
|
|
||||||
-- Articoli Magazzino
|
|
||||||
CREATE TABLE IF NOT EXISTS WarehouseArticles (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
Code TEXT NOT NULL UNIQUE,
|
|
||||||
Description TEXT NOT NULL,
|
|
||||||
ShortDescription TEXT,
|
|
||||||
Barcode TEXT,
|
|
||||||
ManufacturerCode TEXT,
|
|
||||||
CategoryId INTEGER,
|
|
||||||
UnitOfMeasure TEXT NOT NULL DEFAULT 'PZ',
|
|
||||||
SecondaryUnitOfMeasure TEXT,
|
|
||||||
UnitConversionFactor REAL,
|
|
||||||
StockManagement INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsBatchManaged INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsSerialManaged INTEGER NOT NULL DEFAULT 0,
|
|
||||||
HasExpiry INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ExpiryWarningDays INTEGER,
|
|
||||||
MinimumStock REAL,
|
|
||||||
MaximumStock REAL,
|
|
||||||
ReorderPoint REAL,
|
|
||||||
ReorderQuantity REAL,
|
|
||||||
LeadTimeDays INTEGER,
|
|
||||||
ValuationMethod INTEGER,
|
|
||||||
StandardCost REAL,
|
|
||||||
LastPurchaseCost REAL,
|
|
||||||
WeightedAverageCost REAL,
|
|
||||||
BaseSellingPrice REAL,
|
|
||||||
Weight REAL,
|
|
||||||
Volume REAL,
|
|
||||||
Width REAL,
|
|
||||||
Height REAL,
|
|
||||||
Depth REAL,
|
|
||||||
Image BLOB,
|
|
||||||
ImageMimeType TEXT,
|
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (CategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_Code ON WarehouseArticles(Code);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_Barcode ON WarehouseArticles(Barcode);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_CategoryId ON WarehouseArticles(CategoryId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_IsActive ON WarehouseArticles(IsActive);
|
|
||||||
|
|
||||||
-- Partite/Lotti
|
|
||||||
CREATE TABLE IF NOT EXISTS ArticleBatches (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
BatchNumber TEXT NOT NULL,
|
|
||||||
ProductionDate TEXT,
|
|
||||||
ExpiryDate TEXT,
|
|
||||||
SupplierBatch TEXT,
|
|
||||||
SupplierId INTEGER,
|
|
||||||
UnitCost REAL,
|
|
||||||
InitialQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
CurrentQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
ReservedQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
Status INTEGER NOT NULL DEFAULT 0,
|
|
||||||
QualityStatus INTEGER,
|
|
||||||
LastQualityCheckDate TEXT,
|
|
||||||
Certifications TEXT,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(ArticleId, BatchNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_ArticleId_BatchNumber ON ArticleBatches(ArticleId, BatchNumber);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_ExpiryDate ON ArticleBatches(ExpiryDate);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_Status ON ArticleBatches(Status);
|
|
||||||
|
|
||||||
-- Seriali/Matricole
|
|
||||||
CREATE TABLE IF NOT EXISTS ArticleSerials (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
BatchId INTEGER,
|
|
||||||
SerialNumber TEXT NOT NULL,
|
|
||||||
ManufacturerSerial TEXT,
|
|
||||||
ProductionDate TEXT,
|
|
||||||
WarrantyExpiryDate TEXT,
|
|
||||||
CurrentWarehouseId INTEGER,
|
|
||||||
Status INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UnitCost REAL,
|
|
||||||
SupplierId INTEGER,
|
|
||||||
CustomerId INTEGER,
|
|
||||||
SoldDate TEXT,
|
|
||||||
SalesReference TEXT,
|
|
||||||
Attributes TEXT,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (CurrentWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(ArticleId, SerialNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_ArticleId_SerialNumber ON ArticleSerials(ArticleId, SerialNumber);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_Status ON ArticleSerials(Status);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_CurrentWarehouseId ON ArticleSerials(CurrentWarehouseId);
|
|
||||||
|
|
||||||
-- Barcode aggiuntivi
|
|
||||||
CREATE TABLE IF NOT EXISTS ArticleBarcodes (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
Barcode TEXT NOT NULL UNIQUE,
|
|
||||||
Type INTEGER NOT NULL DEFAULT 0,
|
|
||||||
Description TEXT,
|
|
||||||
Quantity REAL NOT NULL DEFAULT 1,
|
|
||||||
IsPrimary INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleBarcodes_Barcode ON ArticleBarcodes(Barcode);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_ArticleBarcodes_ArticleId ON ArticleBarcodes(ArticleId);
|
|
||||||
|
|
||||||
-- Giacenze
|
|
||||||
CREATE TABLE IF NOT EXISTS StockLevels (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
WarehouseId INTEGER NOT NULL,
|
|
||||||
BatchId INTEGER,
|
|
||||||
Quantity REAL NOT NULL DEFAULT 0,
|
|
||||||
ReservedQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
OnOrderQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
StockValue REAL,
|
|
||||||
UnitCost REAL,
|
|
||||||
LastMovementDate TEXT,
|
|
||||||
LastInventoryDate TEXT,
|
|
||||||
LocationCode TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(ArticleId, WarehouseId, BatchId)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockLevels_ArticleId_WarehouseId_BatchId ON StockLevels(ArticleId, WarehouseId, BatchId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockLevels_WarehouseId ON StockLevels(WarehouseId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockLevels_LocationCode ON StockLevels(LocationCode);
|
|
||||||
|
|
||||||
-- Causali Movimento
|
|
||||||
CREATE TABLE IF NOT EXISTS MovementReasons (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
Code TEXT NOT NULL UNIQUE,
|
|
||||||
Description TEXT NOT NULL,
|
|
||||||
MovementType INTEGER NOT NULL,
|
|
||||||
StockSign INTEGER NOT NULL,
|
|
||||||
RequiresExternalReference INTEGER NOT NULL DEFAULT 0,
|
|
||||||
RequiresValuation INTEGER NOT NULL DEFAULT 1,
|
|
||||||
UpdatesAverageCost INTEGER NOT NULL DEFAULT 1,
|
|
||||||
IsSystem INTEGER NOT NULL DEFAULT 0,
|
|
||||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
|
||||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_MovementReasons_Code ON MovementReasons(Code);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_MovementReasons_MovementType ON MovementReasons(MovementType);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_MovementReasons_IsActive ON MovementReasons(IsActive);
|
|
||||||
|
|
||||||
-- Movimenti di Magazzino (Testata)
|
|
||||||
CREATE TABLE IF NOT EXISTS StockMovements (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
DocumentNumber TEXT NOT NULL UNIQUE,
|
|
||||||
MovementDate TEXT NOT NULL,
|
|
||||||
Type INTEGER NOT NULL,
|
|
||||||
ReasonId INTEGER,
|
|
||||||
SourceWarehouseId INTEGER,
|
|
||||||
DestinationWarehouseId INTEGER,
|
|
||||||
ExternalReference TEXT,
|
|
||||||
ExternalDocumentType INTEGER,
|
|
||||||
SupplierId INTEGER,
|
|
||||||
CustomerId INTEGER,
|
|
||||||
Status INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ConfirmedDate TEXT,
|
|
||||||
ConfirmedBy TEXT,
|
|
||||||
TotalValue REAL,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ReasonId) REFERENCES MovementReasons(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (SourceWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (DestinationWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovements_DocumentNumber ON StockMovements(DocumentNumber);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovements_MovementDate ON StockMovements(MovementDate);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovements_Type ON StockMovements(Type);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovements_Status ON StockMovements(Status);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovements_ExternalReference ON StockMovements(ExternalReference);
|
|
||||||
|
|
||||||
-- Movimenti di Magazzino (Righe)
|
|
||||||
CREATE TABLE IF NOT EXISTS StockMovementLines (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
MovementId INTEGER NOT NULL,
|
|
||||||
LineNumber INTEGER NOT NULL,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
BatchId INTEGER,
|
|
||||||
SerialId INTEGER,
|
|
||||||
Quantity REAL NOT NULL,
|
|
||||||
UnitOfMeasure TEXT NOT NULL DEFAULT 'PZ',
|
|
||||||
UnitCost REAL,
|
|
||||||
LineValue REAL,
|
|
||||||
SourceLocationCode TEXT,
|
|
||||||
DestinationLocationCode TEXT,
|
|
||||||
ExternalLineReference TEXT,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (MovementId) REFERENCES StockMovements(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (SerialId) REFERENCES ArticleSerials(Id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(MovementId, LineNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_MovementId_LineNumber ON StockMovementLines(MovementId, LineNumber);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_ArticleId ON StockMovementLines(ArticleId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_BatchId ON StockMovementLines(BatchId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_SerialId ON StockMovementLines(SerialId);
|
|
||||||
|
|
||||||
-- Valorizzazione Magazzino per Periodo
|
|
||||||
CREATE TABLE IF NOT EXISTS StockValuations (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ValuationDate TEXT NOT NULL,
|
|
||||||
Period INTEGER NOT NULL,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
WarehouseId INTEGER,
|
|
||||||
Quantity REAL NOT NULL DEFAULT 0,
|
|
||||||
Method INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UnitCost REAL NOT NULL DEFAULT 0,
|
|
||||||
TotalValue REAL NOT NULL DEFAULT 0,
|
|
||||||
InboundQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
InboundValue REAL NOT NULL DEFAULT 0,
|
|
||||||
OutboundQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
OutboundValue REAL NOT NULL DEFAULT 0,
|
|
||||||
IsClosed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ClosedDate TEXT,
|
|
||||||
ClosedBy TEXT,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(Period, ArticleId, WarehouseId)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockValuations_Period_ArticleId_WarehouseId ON StockValuations(Period, ArticleId, WarehouseId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockValuations_ValuationDate ON StockValuations(ValuationDate);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockValuations_IsClosed ON StockValuations(IsClosed);
|
|
||||||
|
|
||||||
-- Layer Valorizzazione FIFO/LIFO
|
|
||||||
CREATE TABLE IF NOT EXISTS StockValuationLayers (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
WarehouseId INTEGER NOT NULL,
|
|
||||||
BatchId INTEGER,
|
|
||||||
LayerDate TEXT NOT NULL,
|
|
||||||
SourceMovementId INTEGER,
|
|
||||||
OriginalQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
RemainingQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
UnitCost REAL NOT NULL DEFAULT 0,
|
|
||||||
IsExhausted INTEGER NOT NULL DEFAULT 0,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (SourceMovementId) REFERENCES StockMovements(Id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockValuationLayers_ArticleId_WarehouseId_LayerDate ON StockValuationLayers(ArticleId, WarehouseId, LayerDate);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_StockValuationLayers_IsExhausted ON StockValuationLayers(IsExhausted);
|
|
||||||
|
|
||||||
-- Inventari Fisici (Testata)
|
|
||||||
CREATE TABLE IF NOT EXISTS InventoryCounts (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
Code TEXT NOT NULL UNIQUE,
|
|
||||||
Description TEXT NOT NULL,
|
|
||||||
InventoryDate TEXT NOT NULL,
|
|
||||||
WarehouseId INTEGER,
|
|
||||||
CategoryId INTEGER,
|
|
||||||
Type INTEGER NOT NULL DEFAULT 0,
|
|
||||||
Status INTEGER NOT NULL DEFAULT 0,
|
|
||||||
StartDate TEXT,
|
|
||||||
EndDate TEXT,
|
|
||||||
ConfirmedDate TEXT,
|
|
||||||
ConfirmedBy TEXT,
|
|
||||||
AdjustmentMovementId INTEGER,
|
|
||||||
PositiveDifferenceValue REAL,
|
|
||||||
NegativeDifferenceValue REAL,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (CategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (AdjustmentMovementId) REFERENCES StockMovements(Id) ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_Code ON InventoryCounts(Code);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_InventoryDate ON InventoryCounts(InventoryDate);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_Status ON InventoryCounts(Status);
|
|
||||||
|
|
||||||
-- Inventari Fisici (Righe)
|
|
||||||
CREATE TABLE IF NOT EXISTS InventoryCountLines (
|
|
||||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
InventoryCountId INTEGER NOT NULL,
|
|
||||||
ArticleId INTEGER NOT NULL,
|
|
||||||
WarehouseId INTEGER NOT NULL,
|
|
||||||
BatchId INTEGER,
|
|
||||||
LocationCode TEXT,
|
|
||||||
TheoreticalQuantity REAL NOT NULL DEFAULT 0,
|
|
||||||
CountedQuantity REAL,
|
|
||||||
UnitCost REAL,
|
|
||||||
CountedAt TEXT,
|
|
||||||
CountedBy TEXT,
|
|
||||||
SecondCountQuantity REAL,
|
|
||||||
SecondCountBy TEXT,
|
|
||||||
Notes TEXT,
|
|
||||||
CreatedAt TEXT,
|
|
||||||
CreatedBy TEXT,
|
|
||||||
UpdatedAt TEXT,
|
|
||||||
UpdatedBy TEXT,
|
|
||||||
FOREIGN KEY (InventoryCountId) REFERENCES InventoryCounts(Id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
|
|
||||||
UNIQUE(InventoryCountId, ArticleId, WarehouseId, BatchId)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_InventoryCountLines_InventoryCountId_ArticleId ON InventoryCountLines(InventoryCountId, ArticleId, WarehouseId, BatchId);
|
|
||||||
CREATE INDEX IF NOT EXISTS IX_InventoryCountLines_ArticleId ON InventoryCountLines(ArticleId);
|
|
||||||
4354
src/backend/Zentral.Infrastructure/Migrations/20251202233615_SyncModelChanges.Designer.cs
generated
Normal file
4354
src/backend/Zentral.Infrastructure/Migrations/20251202233615_SyncModelChanges.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Zentral.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SyncModelChanges : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ProductionOrders_ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders",
|
||||||
|
column: "ParentProductionOrderId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ProductionOrders_ProductionOrders_ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders",
|
||||||
|
column: "ParentProductionOrderId",
|
||||||
|
principalTable: "ProductionOrders",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ProductionOrders_ProductionOrders_ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ProductionOrders_ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentProductionOrderId",
|
||||||
|
table: "ProductionOrders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Zentral.Infrastructure.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Zentral.Infrastructure.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -1335,6 +1335,9 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentProductionOrderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<decimal>("Quantity")
|
b.Property<decimal>("Quantity")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -1358,6 +1361,8 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("ParentProductionOrderId");
|
||||||
|
|
||||||
b.HasIndex("StartDate");
|
b.HasIndex("StartDate");
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
@@ -3782,7 +3787,13 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Zentral.Domain.Entities.Production.ProductionOrder", "ParentProductionOrder")
|
||||||
|
.WithMany("ChildProductionOrders")
|
||||||
|
.HasForeignKey("ParentProductionOrderId");
|
||||||
|
|
||||||
b.Navigation("Article");
|
b.Navigation("Article");
|
||||||
|
|
||||||
|
b.Navigation("ParentProductionOrder");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrderComponent", b =>
|
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrderComponent", b =>
|
||||||
@@ -4225,6 +4236,8 @@ namespace Zentral.Infrastructure.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrder", b =>
|
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrder", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("ChildProductionOrders");
|
||||||
|
|
||||||
b.Navigation("Components");
|
b.Navigation("Components");
|
||||||
|
|
||||||
b.Navigation("Phases");
|
b.Navigation("Phases");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ModuleGuard } from "./components/ModuleGuard";
|
|||||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||||
import { ModuleProvider } from "./contexts/ModuleContext";
|
import { ModuleProvider } from "./contexts/ModuleContext";
|
||||||
|
import { TabProvider } from "./contexts/TabContext";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -57,80 +58,82 @@ function App() {
|
|||||||
<ModuleProvider>
|
<ModuleProvider>
|
||||||
<CollaborationProvider>
|
<CollaborationProvider>
|
||||||
<RealTimeProvider>
|
<RealTimeProvider>
|
||||||
<Routes>
|
<TabProvider>
|
||||||
<Route path="/" element={<Layout />}>
|
<Routes>
|
||||||
<Route index element={<Dashboard />} />
|
<Route path="/" element={<Layout />}>
|
||||||
<Route path="calendario" element={<CalendarioPage />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="eventi" element={<EventiPage />} />
|
<Route path="calendario" element={<CalendarioPage />} />
|
||||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
<Route path="eventi" element={<EventiPage />} />
|
||||||
<Route path="clienti" element={<ClientiPage />} />
|
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||||
<Route path="location" element={<LocationPage />} />
|
<Route path="clienti" element={<ClientiPage />} />
|
||||||
<Route path="articoli" element={<ArticoliPage />} />
|
<Route path="location" element={<LocationPage />} />
|
||||||
<Route path="risorse" element={<RisorsePage />} />
|
<Route path="articoli" element={<ArticoliPage />} />
|
||||||
<Route
|
<Route path="risorse" element={<RisorsePage />} />
|
||||||
path="report-templates"
|
<Route
|
||||||
element={<ReportTemplatesPage />}
|
path="report-templates"
|
||||||
/>
|
element={<ReportTemplatesPage />}
|
||||||
<Route
|
/>
|
||||||
path="report-editor"
|
<Route
|
||||||
element={<ReportEditorPage />}
|
path="report-editor"
|
||||||
/>
|
element={<ReportEditorPage />}
|
||||||
<Route
|
/>
|
||||||
path="report-editor/:id"
|
<Route
|
||||||
element={<ReportEditorPage />}
|
path="report-editor/:id"
|
||||||
/>
|
element={<ReportEditorPage />}
|
||||||
{/* Admin */}
|
/>
|
||||||
<Route path="modules" element={<ModulesAdminPage />} />
|
{/* Admin */}
|
||||||
<Route
|
<Route path="modules" element={<ModulesAdminPage />} />
|
||||||
path="modules/purchase/:code"
|
<Route
|
||||||
element={<ModulePurchasePage />}
|
path="modules/purchase/:code"
|
||||||
/>
|
element={<ModulePurchasePage />}
|
||||||
<Route
|
/>
|
||||||
path="admin/auto-codes"
|
<Route
|
||||||
element={<AutoCodesAdminPage />}
|
path="admin/auto-codes"
|
||||||
/>
|
element={<AutoCodesAdminPage />}
|
||||||
<Route
|
/>
|
||||||
path="admin/custom-fields"
|
<Route
|
||||||
element={<CustomFieldsAdminPage />}
|
path="admin/custom-fields"
|
||||||
/>
|
element={<CustomFieldsAdminPage />}
|
||||||
{/* Warehouse Module */}
|
/>
|
||||||
<Route
|
{/* Warehouse Module */}
|
||||||
path="warehouse/*"
|
<Route
|
||||||
element={
|
path="warehouse/*"
|
||||||
<ModuleGuard moduleCode="warehouse">
|
element={
|
||||||
<WarehouseRoutes />
|
<ModuleGuard moduleCode="warehouse">
|
||||||
</ModuleGuard>
|
<WarehouseRoutes />
|
||||||
}
|
</ModuleGuard>
|
||||||
/>
|
}
|
||||||
{/* Purchases Module */}
|
/>
|
||||||
<Route
|
{/* Purchases Module */}
|
||||||
path="purchases/*"
|
<Route
|
||||||
element={
|
path="purchases/*"
|
||||||
<ModuleGuard moduleCode="purchases">
|
element={
|
||||||
<PurchasesRoutes />
|
<ModuleGuard moduleCode="purchases">
|
||||||
</ModuleGuard>
|
<PurchasesRoutes />
|
||||||
}
|
</ModuleGuard>
|
||||||
/>
|
}
|
||||||
{/* Sales Module */}
|
/>
|
||||||
<Route
|
{/* Sales Module */}
|
||||||
path="sales/*"
|
<Route
|
||||||
element={
|
path="sales/*"
|
||||||
<ModuleGuard moduleCode="sales">
|
element={
|
||||||
<SalesRoutes />
|
<ModuleGuard moduleCode="sales">
|
||||||
</ModuleGuard>
|
<SalesRoutes />
|
||||||
}
|
</ModuleGuard>
|
||||||
/>
|
}
|
||||||
{/* Production Module */}
|
/>
|
||||||
<Route
|
{/* Production Module */}
|
||||||
path="production/*"
|
<Route
|
||||||
element={
|
path="production/*"
|
||||||
<ModuleGuard moduleCode="production">
|
element={
|
||||||
<ProductionRoutes />
|
<ModuleGuard moduleCode="production">
|
||||||
</ModuleGuard>
|
<ProductionRoutes />
|
||||||
}
|
</ModuleGuard>
|
||||||
/>
|
}
|
||||||
</Route>
|
/>
|
||||||
</Routes>
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</TabProvider>
|
||||||
</RealTimeProvider>
|
</RealTimeProvider>
|
||||||
</CollaborationProvider>
|
</CollaborationProvider>
|
||||||
</ModuleProvider>
|
</ModuleProvider>
|
||||||
|
|||||||
@@ -1,185 +1,46 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Drawer,
|
Drawer,
|
||||||
AppBar,
|
AppBar,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
List,
|
|
||||||
Typography,
|
|
||||||
Divider,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Dashboard as DashboardIcon,
|
|
||||||
Event as EventIcon,
|
|
||||||
People as PeopleIcon,
|
|
||||||
Place as PlaceIcon,
|
|
||||||
Inventory as InventoryIcon,
|
|
||||||
Person as PersonIcon,
|
|
||||||
CalendarMonth as CalendarIcon,
|
|
||||||
Print as PrintIcon,
|
|
||||||
Close as CloseIcon,
|
|
||||||
Extension as ModulesIcon,
|
|
||||||
Warehouse as WarehouseIcon,
|
|
||||||
Code as AutoCodeIcon,
|
|
||||||
ShoppingCart as ShoppingCartIcon,
|
|
||||||
Sell as SellIcon,
|
|
||||||
Factory as ProductionIcon,
|
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||||
import { useModules } from "../contexts/ModuleContext";
|
|
||||||
import { useLanguage } from "../contexts/LanguageContext";
|
|
||||||
import { SettingsSelector } from "./SettingsSelector";
|
import { SettingsSelector } from "./SettingsSelector";
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import TabsBar from "./TabsBar";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
const DRAWER_WIDTH = 280; // Increased width for better readability
|
||||||
const DRAWER_WIDTH_COLLAPSED = 64;
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { activeModules } = useModules();
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
// Breakpoints
|
// Breakpoints
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
|
||||||
const isTablet = useMediaQuery(theme.breakpoints.between("sm", "md")); // 600-900px
|
|
||||||
|
|
||||||
// Drawer width based on screen size
|
|
||||||
const drawerWidth = isTablet ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{ text: t('menu.dashboard'), icon: <DashboardIcon />, path: "/" },
|
|
||||||
{ text: t('menu.calendar'), icon: <CalendarIcon />, path: "/calendario" },
|
|
||||||
{ text: t('menu.events'), icon: <EventIcon />, path: "/eventi" },
|
|
||||||
{ text: t('menu.clients'), icon: <PeopleIcon />, path: "/clienti" },
|
|
||||||
{ text: t('menu.location'), icon: <PlaceIcon />, path: "/location" },
|
|
||||||
{ text: t('menu.articles'), icon: <InventoryIcon />, path: "/articoli" },
|
|
||||||
{ text: t('menu.resources'), icon: <PersonIcon />, path: "/risorse" },
|
|
||||||
{
|
|
||||||
text: t('menu.warehouse'),
|
|
||||||
icon: <WarehouseIcon />,
|
|
||||||
path: "/warehouse",
|
|
||||||
moduleCode: "warehouse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('menu.purchases'),
|
|
||||||
icon: <ShoppingCartIcon />,
|
|
||||||
path: "/purchases/orders",
|
|
||||||
moduleCode: "purchases",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('menu.sales'),
|
|
||||||
icon: <SellIcon />,
|
|
||||||
path: "/sales/orders",
|
|
||||||
moduleCode: "sales",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t('menu.production'),
|
|
||||||
icon: <ProductionIcon />,
|
|
||||||
path: "/production/orders",
|
|
||||||
moduleCode: "production",
|
|
||||||
},
|
|
||||||
{ text: t('menu.reports'), icon: <PrintIcon />, path: "/report-templates" },
|
|
||||||
{ text: t('menu.modules'), icon: <ModulesIcon />, path: "/modules" },
|
|
||||||
{ text: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: "/admin/auto-codes" },
|
|
||||||
{ text: t('menu.customFields'), icon: <AutoCodeIcon />, path: "/admin/custom-fields" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter menu items based on active modules
|
|
||||||
const activeModuleCodes = activeModules.map((m) => m.code);
|
|
||||||
const filteredMenuItems = menuItems.filter(
|
|
||||||
(item) => !item.moduleCode || activeModuleCodes.includes(item.moduleCode),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDrawerToggle = () => {
|
const handleDrawerToggle = () => {
|
||||||
setMobileOpen(!mobileOpen);
|
setMobileOpen(!mobileOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawer = (
|
|
||||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
||||||
<Toolbar
|
|
||||||
sx={{
|
|
||||||
justifyContent: isTablet ? "center" : "space-between",
|
|
||||||
minHeight: { xs: 56, sm: 64 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isTablet && (
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
noWrap
|
|
||||||
component="div"
|
|
||||||
sx={{ fontWeight: "bold" }}
|
|
||||||
>
|
|
||||||
Zentral
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{isMobile && (
|
|
||||||
<IconButton onClick={handleDrawerToggle} sx={{ ml: "auto" }}>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Toolbar>
|
|
||||||
<Divider />
|
|
||||||
<List sx={{ flex: 1, py: 1 }}>
|
|
||||||
{filteredMenuItems.map((item) => (
|
|
||||||
<ListItem key={item.text} disablePadding sx={{ px: 1 }}>
|
|
||||||
<ListItemButton
|
|
||||||
selected={location.pathname === item.path}
|
|
||||||
onClick={() => {
|
|
||||||
navigate(item.path);
|
|
||||||
if (isMobile) setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 1,
|
|
||||||
minHeight: 48,
|
|
||||||
justifyContent: isTablet ? "center" : "flex-start",
|
|
||||||
px: isTablet ? 1 : 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItemIcon
|
|
||||||
sx={{
|
|
||||||
minWidth: isTablet ? 0 : 40,
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</ListItemIcon>
|
|
||||||
{!isTablet && <ListItemText primary={item.text} />}
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
{!isTablet && (
|
|
||||||
<Box sx={{ p: 2, borderTop: 1, borderColor: "divider" }}>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
© 2025 Zentral
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||||
<AppBar
|
<AppBar
|
||||||
position="fixed"
|
position="fixed"
|
||||||
sx={{
|
sx={{
|
||||||
width: {
|
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||||
xs: "100%",
|
ml: { sm: `${DRAWER_WIDTH}px` },
|
||||||
sm: `calc(100% - ${drawerWidth}px)`,
|
|
||||||
},
|
|
||||||
ml: { sm: `${drawerWidth}px` },
|
|
||||||
boxShadow: 1,
|
boxShadow: 1,
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }}>
|
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }}>
|
||||||
@@ -192,17 +53,9 @@ export default function Layout() {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography
|
|
||||||
variant="h6"
|
{/* Search Bar */}
|
||||||
noWrap
|
<SearchBar />
|
||||||
component="div"
|
|
||||||
sx={{
|
|
||||||
fontSize: { xs: "1rem", sm: "1.25rem" },
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isMobile ? "Zentral" : "Catering & Banqueting Management"}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Collaboration Indicator */}
|
{/* Collaboration Indicator */}
|
||||||
<CollaborationIndicator compact={isMobile} />
|
<CollaborationIndicator compact={isMobile} />
|
||||||
@@ -216,7 +69,7 @@ export default function Layout() {
|
|||||||
<Box
|
<Box
|
||||||
component="nav"
|
component="nav"
|
||||||
sx={{
|
sx={{
|
||||||
width: { sm: drawerWidth },
|
width: { sm: DRAWER_WIDTH },
|
||||||
flexShrink: { sm: 0 },
|
flexShrink: { sm: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -233,26 +86,22 @@ export default function Layout() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{drawer}
|
<Sidebar onClose={handleDrawerToggle} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Desktop/Tablet Drawer */}
|
{/* Desktop Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: "none", sm: "block" },
|
display: { xs: "none", sm: "block" },
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
width: drawerWidth,
|
width: DRAWER_WIDTH,
|
||||||
transition: theme.transitions.create("width", {
|
|
||||||
easing: theme.transitions.easing.sharp,
|
|
||||||
duration: theme.transitions.duration.enteringScreen,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
open
|
open
|
||||||
>
|
>
|
||||||
{drawer}
|
<Sidebar />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -263,7 +112,7 @@ export default function Layout() {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
width: {
|
width: {
|
||||||
xs: "100vw",
|
xs: "100vw",
|
||||||
sm: `calc(100vw - ${drawerWidth}px)`,
|
sm: `calc(100vw - ${DRAWER_WIDTH}px)`,
|
||||||
},
|
},
|
||||||
height: "100vh",
|
height: "100vh",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -274,19 +123,23 @@ export default function Layout() {
|
|||||||
{/* Toolbar spacer */}
|
{/* Toolbar spacer */}
|
||||||
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, flexShrink: 0 }} />
|
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Tabs Bar */}
|
||||||
|
<TabsBar />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
minHeight: 0, // Important: allows flex children to shrink below content size
|
minHeight: 0,
|
||||||
p: location.pathname.startsWith("/report-editor")
|
p: location.pathname.startsWith("/report-editor")
|
||||||
? 0
|
? 0
|
||||||
: { xs: 1.5, sm: 2, md: 3 },
|
: { xs: 1.5, sm: 2, md: 3 },
|
||||||
overflow: location.pathname.startsWith("/report-editor")
|
overflow: location.pathname.startsWith("/report-editor")
|
||||||
? "hidden"
|
? "hidden"
|
||||||
: "auto",
|
: "auto",
|
||||||
|
bgcolor: 'background.default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
164
src/frontend/src/components/SearchBar.tsx
Normal file
164
src/frontend/src/components/SearchBar.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { styled, alpha } from '@mui/material/styles';
|
||||||
|
import InputBase from '@mui/material/InputBase';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import { Autocomplete, Box, Typography } from '@mui/material';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useModules } from '../contexts/ModuleContext';
|
||||||
|
import { useTabs } from '../contexts/TabContext';
|
||||||
|
|
||||||
|
const Search = styled('div')(({ theme }) => ({
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
backgroundColor: alpha(theme.palette.common.white, 0.15),
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: alpha(theme.palette.common.white, 0.25),
|
||||||
|
},
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
marginLeft: 0,
|
||||||
|
width: '100%',
|
||||||
|
[theme.breakpoints.up('sm')]: {
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
width: 'auto',
|
||||||
|
minWidth: '300px',
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SearchIconWrapper = styled('div')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(0, 2),
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||||
|
color: 'inherit',
|
||||||
|
width: '100%',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
padding: theme.spacing(1, 1, 1, 0),
|
||||||
|
// vertical padding + font size from searchIcon
|
||||||
|
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||||
|
transition: theme.transitions.create('width'),
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface SearchOption {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { activeModules } = useModules();
|
||||||
|
const { openTab } = useTabs();
|
||||||
|
|
||||||
|
const activeModuleCodes = activeModules.map((m) => m.code);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const opts: SearchOption[] = [
|
||||||
|
// Core
|
||||||
|
{ label: t('menu.dashboard'), path: '/', category: 'Zentral' },
|
||||||
|
{ label: t('menu.calendar'), path: '/calendario', category: 'Zentral' },
|
||||||
|
{ label: t('menu.events'), path: '/eventi', category: 'Zentral' },
|
||||||
|
{ label: t('menu.clients'), path: '/clienti', category: 'Zentral' },
|
||||||
|
{ label: t('menu.location'), path: '/location', category: 'Zentral' },
|
||||||
|
{ label: t('menu.articles'), path: '/articoli', category: 'Zentral' },
|
||||||
|
{ label: t('menu.resources'), path: '/risorse', category: 'Zentral' },
|
||||||
|
{ label: t('menu.reports'), path: '/report-templates', category: 'Zentral' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (activeModuleCodes.includes('warehouse')) {
|
||||||
|
opts.push(
|
||||||
|
{ label: 'Warehouse Dashboard', path: '/warehouse', category: 'Warehouse' },
|
||||||
|
{ label: 'Warehouse Articles', path: '/warehouse/articles', category: 'Warehouse' },
|
||||||
|
{ label: 'Warehouse Locations', path: '/warehouse/locations', category: 'Warehouse' },
|
||||||
|
{ label: 'Warehouse Movements', path: '/warehouse/movements', category: 'Warehouse' },
|
||||||
|
{ label: 'Warehouse Stock', path: '/warehouse/stock', category: 'Warehouse' },
|
||||||
|
{ label: 'Warehouse Inventory', path: '/warehouse/inventory', category: 'Warehouse' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModuleCodes.includes('purchases')) {
|
||||||
|
opts.push(
|
||||||
|
{ label: 'Purchases Suppliers', path: '/purchases/suppliers', category: 'Purchases' },
|
||||||
|
{ label: 'Purchases Orders', path: '/purchases/orders', category: 'Purchases' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModuleCodes.includes('sales')) {
|
||||||
|
opts.push(
|
||||||
|
{ label: 'Sales Orders', path: '/sales/orders', category: 'Sales' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeModuleCodes.includes('production')) {
|
||||||
|
opts.push(
|
||||||
|
{ label: 'Production Dashboard', path: '/production', category: 'Production' },
|
||||||
|
{ label: 'Production Orders', path: '/production/orders', category: 'Production' },
|
||||||
|
{ label: 'Bill of Materials', path: '/production/bom', category: 'Production' },
|
||||||
|
{ label: 'Work Centers', path: '/production/work-centers', category: 'Production' },
|
||||||
|
{ label: 'Production Cycles', path: '/production/cycles', category: 'Production' },
|
||||||
|
{ label: 'MRP', path: '/production/mrp', category: 'Production' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.push(
|
||||||
|
{ label: t('menu.modules'), path: '/modules', category: 'Admin' },
|
||||||
|
{ label: t('menu.autoCodes'), path: '/admin/auto-codes', category: 'Admin' },
|
||||||
|
{ label: t('menu.customFields'), path: '/admin/custom-fields', category: 'Admin' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return opts;
|
||||||
|
}, [activeModuleCodes, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
id="search-bar"
|
||||||
|
disableClearable
|
||||||
|
sx={{ flexGrow: 1, maxWidth: 600, mx: 2 }}
|
||||||
|
options={options}
|
||||||
|
groupBy={(option) => option.category}
|
||||||
|
getOptionLabel={(option) => typeof option === 'string' ? option : option.label}
|
||||||
|
onChange={(_, value) => {
|
||||||
|
if (typeof value !== 'string' && value) {
|
||||||
|
openTab(value.path, value.label);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => {
|
||||||
|
const { InputLabelProps, InputProps, ...rest } = params;
|
||||||
|
return (
|
||||||
|
<Search>
|
||||||
|
<SearchIconWrapper>
|
||||||
|
<SearchIcon />
|
||||||
|
</SearchIconWrapper>
|
||||||
|
<StyledInputBase
|
||||||
|
{...InputProps}
|
||||||
|
{...rest}
|
||||||
|
placeholder="Search..."
|
||||||
|
inputProps={{ ...params.inputProps, 'aria-label': 'search' }}
|
||||||
|
/>
|
||||||
|
</Search>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
return (
|
||||||
|
<li {...props} key={option.path}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">{option.label}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{option.category}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/frontend/src/components/Sidebar.tsx
Normal file
225
src/frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Collapse,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore,
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Event as EventIcon,
|
||||||
|
People as PeopleIcon,
|
||||||
|
Place as PlaceIcon,
|
||||||
|
Inventory as InventoryIcon,
|
||||||
|
Person as PersonIcon,
|
||||||
|
CalendarMonth as CalendarIcon,
|
||||||
|
Print as PrintIcon,
|
||||||
|
Extension as ModulesIcon,
|
||||||
|
Warehouse as WarehouseIcon,
|
||||||
|
Code as AutoCodeIcon,
|
||||||
|
ShoppingCart as ShoppingCartIcon,
|
||||||
|
Sell as SellIcon,
|
||||||
|
Factory as ProductionIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Storage as StorageIcon,
|
||||||
|
SwapHoriz as SwapIcon,
|
||||||
|
Assignment as AssignmentIcon,
|
||||||
|
ListAlt as ListAltIcon,
|
||||||
|
Build as BuildIcon,
|
||||||
|
Timeline as TimelineIcon,
|
||||||
|
PrecisionManufacturing as ManufacturingIcon,
|
||||||
|
Category as CategoryIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useModules } from '../contexts/ModuleContext';
|
||||||
|
import { useTabs } from '../contexts/TabContext';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
path?: string;
|
||||||
|
children?: MenuItem[];
|
||||||
|
moduleCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ onClose }: { onClose?: () => void }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { activeModules } = useModules();
|
||||||
|
const { openTab } = useTabs();
|
||||||
|
const location = useLocation();
|
||||||
|
const [openItems, setOpenItems] = useState<Record<string, boolean>>({
|
||||||
|
core: true,
|
||||||
|
warehouse: false,
|
||||||
|
purchases: false,
|
||||||
|
sales: false,
|
||||||
|
production: false,
|
||||||
|
admin: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = (id: string) => {
|
||||||
|
setOpenItems((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemClick = (item: MenuItem) => {
|
||||||
|
if (item.path) {
|
||||||
|
openTab(item.path, item.label);
|
||||||
|
if (onClose) onClose();
|
||||||
|
} else if (item.children) {
|
||||||
|
handleToggle(item.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuStructure: MenuItem[] = [
|
||||||
|
{
|
||||||
|
id: 'core',
|
||||||
|
label: 'Zentral',
|
||||||
|
icon: <DashboardIcon />,
|
||||||
|
children: [
|
||||||
|
{ id: 'dashboard', label: t('menu.dashboard'), icon: <DashboardIcon />, path: '/' },
|
||||||
|
{ id: 'calendar', label: t('menu.calendar'), icon: <CalendarIcon />, path: '/calendario' },
|
||||||
|
{ id: 'events', label: t('menu.events'), icon: <EventIcon />, path: '/eventi' },
|
||||||
|
{ id: 'clients', label: t('menu.clients'), icon: <PeopleIcon />, path: '/clienti' },
|
||||||
|
{ id: 'location', label: t('menu.location'), icon: <PlaceIcon />, path: '/location' },
|
||||||
|
{ id: 'articles', label: t('menu.articles'), icon: <InventoryIcon />, path: '/articoli' },
|
||||||
|
{ id: 'resources', label: t('menu.resources'), icon: <PersonIcon />, path: '/risorse' },
|
||||||
|
{ id: 'reports', label: t('menu.reports'), icon: <PrintIcon />, path: '/report-templates' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warehouse',
|
||||||
|
label: t('menu.warehouse'),
|
||||||
|
icon: <WarehouseIcon />,
|
||||||
|
moduleCode: 'warehouse',
|
||||||
|
children: [
|
||||||
|
{ id: 'wh-dashboard', label: 'Dashboard', icon: <DashboardIcon />, path: '/warehouse' },
|
||||||
|
{ id: 'wh-articles', label: t('menu.articles'), icon: <CategoryIcon />, path: '/warehouse/articles' },
|
||||||
|
{ id: 'wh-locations', label: t('menu.location'), icon: <PlaceIcon />, path: '/warehouse/locations' },
|
||||||
|
{ id: 'wh-movements', label: 'Movimenti', icon: <SwapIcon />, path: '/warehouse/movements' },
|
||||||
|
{ id: 'wh-stock', label: 'Giacenze', icon: <StorageIcon />, path: '/warehouse/stock' },
|
||||||
|
{ id: 'wh-inventory', label: 'Inventario', icon: <AssignmentIcon />, path: '/warehouse/inventory' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchases',
|
||||||
|
label: t('menu.purchases'),
|
||||||
|
icon: <ShoppingCartIcon />,
|
||||||
|
moduleCode: 'purchases',
|
||||||
|
children: [
|
||||||
|
{ id: 'pur-suppliers', label: 'Fornitori', icon: <PeopleIcon />, path: '/purchases/suppliers' },
|
||||||
|
{ id: 'pur-orders', label: 'Ordini Acquisto', icon: <ListAltIcon />, path: '/purchases/orders' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sales',
|
||||||
|
label: t('menu.sales'),
|
||||||
|
icon: <SellIcon />,
|
||||||
|
moduleCode: 'sales',
|
||||||
|
children: [
|
||||||
|
{ id: 'sal-orders', label: 'Ordini Vendita', icon: <ListAltIcon />, path: '/sales/orders' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'production',
|
||||||
|
label: t('menu.production'),
|
||||||
|
icon: <ProductionIcon />,
|
||||||
|
moduleCode: 'production',
|
||||||
|
children: [
|
||||||
|
{ id: 'prod-dashboard', label: 'Dashboard', icon: <DashboardIcon />, path: '/production' },
|
||||||
|
{ id: 'prod-orders', label: 'Ordini Produzione', icon: <ListAltIcon />, path: '/production/orders' },
|
||||||
|
{ id: 'prod-bom', label: 'Distinte Base', icon: <AssignmentIcon />, path: '/production/bom' },
|
||||||
|
{ id: 'prod-workcenters', label: 'Centri di Lavoro', icon: <BuildIcon />, path: '/production/work-centers' },
|
||||||
|
{ id: 'prod-cycles', label: 'Cicli', icon: <TimelineIcon />, path: '/production/cycles' },
|
||||||
|
{ id: 'prod-mrp', label: 'MRP', icon: <ManufacturingIcon />, path: '/production/mrp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
label: 'Amministrazione',
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
children: [
|
||||||
|
{ id: 'modules', label: t('menu.modules'), icon: <ModulesIcon />, path: '/modules' },
|
||||||
|
{ id: 'autocodes', label: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: '/admin/auto-codes' },
|
||||||
|
{ id: 'customfields', label: t('menu.customFields'), icon: <AutoCodeIcon />, path: '/admin/custom-fields' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeModuleCodes = activeModules.map((m) => m.code);
|
||||||
|
|
||||||
|
const renderMenuItem = (item: MenuItem, level: number = 0) => {
|
||||||
|
// Filter by module
|
||||||
|
if (item.moduleCode && !activeModuleCodes.includes(item.moduleCode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
const isOpen = openItems[item.id];
|
||||||
|
const isSelected = item.path ? location.pathname === item.path : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
<ListItem disablePadding sx={{ display: 'block' }}>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
selected={isSelected}
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
justifyContent: 'initial',
|
||||||
|
px: 2.5,
|
||||||
|
pl: level * 2 + 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
mr: 3,
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: isSelected ? 'primary.main' : 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight: isSelected ? 'bold' : 'medium',
|
||||||
|
fontSize: level === 0 ? '0.95rem' : '0.9rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasChildren ? (isOpen ? <ExpandLess /> : <ExpandMore />) : null}
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
{hasChildren && (
|
||||||
|
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||||
|
<List component="div" disablePadding>
|
||||||
|
{item.children?.map((child) => renderMenuItem(child, level + 1))}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ overflow: 'auto' }}>
|
||||||
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
|
||||||
|
Zentral
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{menuStructure.map((item) => renderMenuItem(item))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/frontend/src/components/TabsBar.tsx
Normal file
88
src/frontend/src/components/TabsBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Tabs, Tab, IconButton } from '@mui/material';
|
||||||
|
import { Close as CloseIcon } from '@mui/icons-material';
|
||||||
|
import { useTabs } from '../contexts/TabContext';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
export default function TabsBar() {
|
||||||
|
const { tabs, activeTabPath, setActiveTab, closeTab } = useTabs();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||||
|
setActiveTab(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
minHeight: 48,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflowX: 'auto',
|
||||||
|
'&::-webkit-scrollbar': {
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-track': {
|
||||||
|
backgroundColor: theme.palette.grey[100],
|
||||||
|
},
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
backgroundColor: theme.palette.grey[400],
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={activeTabPath}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
aria-label="app tabs"
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
'& .MuiTab-root': {
|
||||||
|
minHeight: 48,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 2,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.path}
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.closable && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
component="span"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeTab(tab.path);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
ml: 1,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
color: 'error.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
value={tab.path}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/frontend/src/contexts/TabContext.tsx
Normal file
117
src/frontend/src/contexts/TabContext.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface Tab {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
closable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabContextType {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabPath: string;
|
||||||
|
openTab: (path: string, label: string, closable?: boolean) => void;
|
||||||
|
closeTab: (path: string) => void;
|
||||||
|
setActiveTab: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function TabProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||||
|
const [activeTabPath, setActiveTabPath] = useState<string>('/');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Load tabs from local storage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTabs = localStorage.getItem('zentral_tabs');
|
||||||
|
const savedActiveTab = localStorage.getItem('zentral_active_tab');
|
||||||
|
|
||||||
|
if (savedTabs) {
|
||||||
|
try {
|
||||||
|
const parsedTabs = JSON.parse(savedTabs);
|
||||||
|
if (Array.isArray(parsedTabs) && parsedTabs.length > 0) {
|
||||||
|
setTabs(parsedTabs);
|
||||||
|
} else {
|
||||||
|
// Default tab
|
||||||
|
setTabs([{ path: '/', label: 'Dashboard', closable: false }]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse tabs", e);
|
||||||
|
setTabs([{ path: '/', label: 'Dashboard', closable: false }]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTabs([{ path: '/', label: 'Dashboard', closable: false }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedActiveTab) {
|
||||||
|
setActiveTabPath(savedActiveTab);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save tabs to local storage whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (tabs.length > 0) {
|
||||||
|
localStorage.setItem('zentral_tabs', JSON.stringify(tabs));
|
||||||
|
}
|
||||||
|
localStorage.setItem('zentral_active_tab', activeTabPath);
|
||||||
|
}, [tabs, activeTabPath]);
|
||||||
|
|
||||||
|
// Sync active tab with location
|
||||||
|
useEffect(() => {
|
||||||
|
// If the current location is not the active tab, update active tab if it exists in tabs
|
||||||
|
// This handles browser back/forward buttons
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
const tabExists = tabs.find(t => t.path === currentPath);
|
||||||
|
if (tabExists && activeTabPath !== currentPath) {
|
||||||
|
setActiveTabPath(currentPath);
|
||||||
|
}
|
||||||
|
}, [location.pathname, tabs, activeTabPath]);
|
||||||
|
|
||||||
|
const openTab = (path: string, label: string, closable: boolean = true) => {
|
||||||
|
if (!tabs.find((t) => t.path === path)) {
|
||||||
|
setTabs((prev) => [...prev, { path, label, closable }]);
|
||||||
|
}
|
||||||
|
setActiveTabPath(path);
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTab = (path: string) => {
|
||||||
|
const tabIndex = tabs.findIndex((t) => t.path === path);
|
||||||
|
if (tabIndex === -1) return;
|
||||||
|
|
||||||
|
const newTabs = tabs.filter((t) => t.path !== path);
|
||||||
|
setTabs(newTabs);
|
||||||
|
|
||||||
|
if (activeTabPath === path) {
|
||||||
|
// Switch to the nearest tab
|
||||||
|
const newActiveTab = newTabs[tabIndex] || newTabs[tabIndex - 1] || newTabs[0];
|
||||||
|
if (newActiveTab) {
|
||||||
|
setActiveTabPath(newActiveTab.path);
|
||||||
|
navigate(newActiveTab.path);
|
||||||
|
} else {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveTab = (path: string) => {
|
||||||
|
setActiveTabPath(path);
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabContext.Provider value={{ tabs, activeTabPath, openTab, closeTab, setActiveTab }}>
|
||||||
|
{children}
|
||||||
|
</TabContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabs() {
|
||||||
|
const context = useContext(TabContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTabs must be used within a TabProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user