feat: Introduce dynamic tab management, sidebar, and search bar components, update backend database schema, and remove old SQL schema.

This commit is contained in:
2025-12-03 00:42:03 +01:00
parent 30a86848bf
commit 772d4632c9
13 changed files with 5183 additions and 665 deletions

View File

@@ -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);

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -1,9 +1,9 @@
// <auto-generated />
using System;
using Zentral.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Zentral.Infrastructure.Data;
#nullable disable
@@ -1335,6 +1335,9 @@ namespace Zentral.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int?>("ParentProductionOrderId")
.HasColumnType("INTEGER");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("TEXT");
@@ -1358,6 +1361,8 @@ namespace Zentral.Infrastructure.Migrations
b.HasIndex("Code")
.IsUnique();
b.HasIndex("ParentProductionOrderId");
b.HasIndex("StartDate");
b.HasIndex("Status");
@@ -3782,7 +3787,13 @@ namespace Zentral.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Zentral.Domain.Entities.Production.ProductionOrder", "ParentProductionOrder")
.WithMany("ChildProductionOrders")
.HasForeignKey("ParentProductionOrderId");
b.Navigation("Article");
b.Navigation("ParentProductionOrder");
});
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrderComponent", b =>
@@ -4225,6 +4236,8 @@ namespace Zentral.Infrastructure.Migrations
modelBuilder.Entity("Zentral.Domain.Entities.Production.ProductionOrder", b =>
{
b.Navigation("ChildProductionOrders");
b.Navigation("Components");
b.Navigation("Phases");

View File

@@ -28,6 +28,7 @@ import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
import { ModuleProvider } from "./contexts/ModuleContext";
import { TabProvider } from "./contexts/TabContext";
const queryClient = new QueryClient({
defaultOptions: {
@@ -57,80 +58,82 @@ function App() {
<ModuleProvider>
<CollaborationProvider>
<RealTimeProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi" element={<EventiPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="clienti" element={<ClientiPage />} />
<Route path="location" element={<LocationPage />} />
<Route path="articoli" element={<ArticoliPage />} />
<Route path="risorse" element={<RisorsePage />} />
<Route
path="report-templates"
element={<ReportTemplatesPage />}
/>
<Route
path="report-editor"
element={<ReportEditorPage />}
/>
<Route
path="report-editor/:id"
element={<ReportEditorPage />}
/>
{/* Admin */}
<Route path="modules" element={<ModulesAdminPage />} />
<Route
path="modules/purchase/:code"
element={<ModulePurchasePage />}
/>
<Route
path="admin/auto-codes"
element={<AutoCodesAdminPage />}
/>
<Route
path="admin/custom-fields"
element={<CustomFieldsAdminPage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"
element={
<ModuleGuard moduleCode="warehouse">
<WarehouseRoutes />
</ModuleGuard>
}
/>
{/* Purchases Module */}
<Route
path="purchases/*"
element={
<ModuleGuard moduleCode="purchases">
<PurchasesRoutes />
</ModuleGuard>
}
/>
{/* Sales Module */}
<Route
path="sales/*"
element={
<ModuleGuard moduleCode="sales">
<SalesRoutes />
</ModuleGuard>
}
/>
{/* Production Module */}
<Route
path="production/*"
element={
<ModuleGuard moduleCode="production">
<ProductionRoutes />
</ModuleGuard>
}
/>
</Route>
</Routes>
<TabProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi" element={<EventiPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="clienti" element={<ClientiPage />} />
<Route path="location" element={<LocationPage />} />
<Route path="articoli" element={<ArticoliPage />} />
<Route path="risorse" element={<RisorsePage />} />
<Route
path="report-templates"
element={<ReportTemplatesPage />}
/>
<Route
path="report-editor"
element={<ReportEditorPage />}
/>
<Route
path="report-editor/:id"
element={<ReportEditorPage />}
/>
{/* Admin */}
<Route path="modules" element={<ModulesAdminPage />} />
<Route
path="modules/purchase/:code"
element={<ModulePurchasePage />}
/>
<Route
path="admin/auto-codes"
element={<AutoCodesAdminPage />}
/>
<Route
path="admin/custom-fields"
element={<CustomFieldsAdminPage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"
element={
<ModuleGuard moduleCode="warehouse">
<WarehouseRoutes />
</ModuleGuard>
}
/>
{/* Purchases Module */}
<Route
path="purchases/*"
element={
<ModuleGuard moduleCode="purchases">
<PurchasesRoutes />
</ModuleGuard>
}
/>
{/* Sales Module */}
<Route
path="sales/*"
element={
<ModuleGuard moduleCode="sales">
<SalesRoutes />
</ModuleGuard>
}
/>
{/* Production Module */}
<Route
path="production/*"
element={
<ModuleGuard moduleCode="production">
<ProductionRoutes />
</ModuleGuard>
}
/>
</Route>
</Routes>
</TabProvider>
</RealTimeProvider>
</CollaborationProvider>
</ModuleProvider>

View File

@@ -1,185 +1,46 @@
import { useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { Outlet, useLocation } from "react-router-dom";
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
Typography,
Divider,
IconButton,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
useMediaQuery,
useTheme,
} from "@mui/material";
import {
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";
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
import { useModules } from "../contexts/ModuleContext";
import { useLanguage } from "../contexts/LanguageContext";
import { SettingsSelector } from "./SettingsSelector";
import Sidebar from "./Sidebar";
import SearchBar from "./SearchBar";
import TabsBar from "./TabsBar";
const DRAWER_WIDTH = 240;
const DRAWER_WIDTH_COLLAPSED = 64;
const DRAWER_WIDTH = 280; // Increased width for better readability
export default function Layout() {
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const { activeModules } = useModules();
const { t } = useLanguage();
// Breakpoints
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 = () => {
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 (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
position="fixed"
sx={{
width: {
xs: "100%",
sm: `calc(100% - ${drawerWidth}px)`,
},
ml: { sm: `${drawerWidth}px` },
width: { sm: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { sm: `${DRAWER_WIDTH}px` },
boxShadow: 1,
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 } }}>
@@ -192,17 +53,9 @@ export default function Layout() {
>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
noWrap
component="div"
sx={{
fontSize: { xs: "1rem", sm: "1.25rem" },
flexGrow: 1,
}}
>
{isMobile ? "Zentral" : "Catering & Banqueting Management"}
</Typography>
{/* Search Bar */}
<SearchBar />
{/* Collaboration Indicator */}
<CollaborationIndicator compact={isMobile} />
@@ -216,7 +69,7 @@ export default function Layout() {
<Box
component="nav"
sx={{
width: { sm: drawerWidth },
width: { sm: DRAWER_WIDTH },
flexShrink: { sm: 0 },
}}
>
@@ -233,26 +86,22 @@ export default function Layout() {
},
}}
>
{drawer}
<Sidebar onClose={handleDrawerToggle} />
</Drawer>
{/* Desktop/Tablet Drawer */}
{/* Desktop Drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: "none", sm: "block" },
"& .MuiDrawer-paper": {
boxSizing: "border-box",
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
width: DRAWER_WIDTH,
},
}}
open
>
{drawer}
<Sidebar />
</Drawer>
</Box>
@@ -263,7 +112,7 @@ export default function Layout() {
flexGrow: 1,
width: {
xs: "100vw",
sm: `calc(100vw - ${drawerWidth}px)`,
sm: `calc(100vw - ${DRAWER_WIDTH}px)`,
},
height: "100vh",
display: "flex",
@@ -274,19 +123,23 @@ export default function Layout() {
{/* Toolbar spacer */}
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, flexShrink: 0 }} />
{/* Tabs Bar */}
<TabsBar />
{/* Content */}
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0, // Important: allows flex children to shrink below content size
minHeight: 0,
p: location.pathname.startsWith("/report-editor")
? 0
: { xs: 1.5, sm: 2, md: 3 },
overflow: location.pathname.startsWith("/report-editor")
? "hidden"
: "auto",
bgcolor: 'background.default',
}}
>
<Outlet />

View 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>
);
}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}