This commit is contained in:
2025-11-29 14:52:39 +01:00
parent bb2d0729e1
commit c7dbcde5dd
49 changed files with 23088 additions and 5 deletions

View File

@@ -0,0 +1,722 @@
import { createContext, useContext, useState, ReactNode } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArticleDto,
WarehouseLocationDto,
CreateArticleDto,
UpdateArticleDto,
CreateWarehouseDto,
UpdateWarehouseDto,
CreateCategoryDto,
UpdateCategoryDto,
CreateMovementDto,
CreateTransferDto,
CreateBatchDto,
CreateSerialDto,
CreateSerialsBulkDto,
CreateInventoryCountDto,
ArticleFilterDto,
MovementFilterDto,
StockLevelFilterDto,
BatchStatus,
SerialStatus,
InventoryStatus,
} from "../types";
import {
articleService,
warehouseLocationService,
categoryService,
movementService,
batchService,
serialService,
stockService,
inventoryService,
} from "../services/warehouseService";
// Query keys for React Query
export const warehouseQueryKeys = {
articles: ["warehouse", "articles"] as const,
article: (id: number) => ["warehouse", "articles", id] as const,
articleStock: (id: number) => ["warehouse", "articles", id, "stock"] as const,
locations: ["warehouse", "locations"] as const,
location: (id: number) => ["warehouse", "locations", id] as const,
categories: ["warehouse", "categories"] as const,
categoryTree: ["warehouse", "categories", "tree"] as const,
movements: ["warehouse", "movements"] as const,
movement: (id: number) => ["warehouse", "movements", id] as const,
batches: ["warehouse", "batches"] as const,
batch: (id: number) => ["warehouse", "batches", id] as const,
serials: ["warehouse", "serials"] as const,
serial: (id: number) => ["warehouse", "serials", id] as const,
stockLevels: ["warehouse", "stock-levels"] as const,
inventories: ["warehouse", "inventories"] as const,
inventory: (id: number) => ["warehouse", "inventories", id] as const,
};
// Context state interface
interface WarehouseContextState {
selectedArticle: ArticleDto | null;
selectedWarehouse: WarehouseLocationDto | null;
setSelectedArticle: (article: ArticleDto | null) => void;
setSelectedWarehouse: (warehouse: WarehouseLocationDto | null) => void;
}
const WarehouseContext = createContext<WarehouseContextState | undefined>(
undefined,
);
export function WarehouseProvider({ children }: { children: ReactNode }) {
const [selectedArticle, setSelectedArticle] = useState<ArticleDto | null>(
null,
);
const [selectedWarehouse, setSelectedWarehouse] =
useState<WarehouseLocationDto | null>(null);
const value: WarehouseContextState = {
selectedArticle,
selectedWarehouse,
setSelectedArticle,
setSelectedWarehouse,
};
return (
<WarehouseContext.Provider value={value}>
{children}
</WarehouseContext.Provider>
);
}
export function useWarehouseContext() {
const context = useContext(WarehouseContext);
if (!context) {
throw new Error(
"useWarehouseContext must be used within a WarehouseProvider",
);
}
return context;
}
// ============================================
// ARTICLES HOOKS
// ============================================
export function useArticles(filter?: ArticleFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.articles, filter],
queryFn: () => articleService.getAll(filter),
});
}
export function useArticle(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.article(id!),
queryFn: () => articleService.getById(id!),
enabled: !!id,
});
}
export function useArticleStock(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.articleStock(id!),
queryFn: () => articleService.getStock(id!),
enabled: !!id,
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateArticleDto) => articleService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
},
});
}
export function useUpdateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateArticleDto }) =>
articleService.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.article(id),
});
},
});
}
export function useDeleteArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => articleService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
},
});
}
export function useUploadArticleImage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, file }: { id: number; file: File }) =>
articleService.uploadImage(id, file),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.article(id),
});
},
});
}
// ============================================
// WAREHOUSE LOCATIONS HOOKS
// ============================================
export function useWarehouses(params?: { active?: boolean }) {
return useQuery({
queryKey: [...warehouseQueryKeys.locations, params],
queryFn: () => warehouseLocationService.getAll(params?.active),
});
}
export function useWarehouse(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.location(id!),
queryFn: () => warehouseLocationService.getById(id!),
enabled: !!id,
});
}
export function useCreateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWarehouseDto) =>
warehouseLocationService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
export function useUpdateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateWarehouseDto }) =>
warehouseLocationService.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.location(id),
});
},
});
}
export function useDeleteWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => warehouseLocationService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
export function useSetDefaultWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => warehouseLocationService.setDefault(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
// ============================================
// CATEGORIES HOOKS
// ============================================
export function useCategories() {
return useQuery({
queryKey: warehouseQueryKeys.categories,
queryFn: () => categoryService.getAll(),
});
}
export function useCategoryTree() {
return useQuery({
queryKey: warehouseQueryKeys.categoryTree,
queryFn: () => categoryService.getTree(),
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategoryDto) => categoryService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateCategoryDto }) =>
categoryService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => categoryService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
// ============================================
// STOCK MOVEMENTS HOOKS
// ============================================
export function useMovements(filter?: MovementFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.movements, filter],
queryFn: () => movementService.getAll(filter),
});
}
export function useMovement(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.movement(id!),
queryFn: () => movementService.getById(id!),
enabled: !!id,
});
}
export function useCreateInboundMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createInbound(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateOutboundMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createOutbound(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateTransferMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTransferDto) =>
movementService.createTransfer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateAdjustmentMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createAdjustment(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useConfirmMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.confirm(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.movement(id),
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useCancelMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.movement(id),
});
},
});
}
export function useDeleteMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
},
});
}
// ============================================
// BATCHES HOOKS
// ============================================
export function useBatches(articleId?: number, status?: BatchStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, articleId, status],
queryFn: () => batchService.getAll(articleId, status),
});
}
export function useBatch(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.batch(id!),
queryFn: () => batchService.getById(id!),
enabled: !!id,
});
}
export function useArticleBatches(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, "article", articleId],
queryFn: () => batchService.getAll(articleId),
enabled: !!articleId,
});
}
export function useExpiringBatches(days: number = 30) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, "expiring", days],
queryFn: () => batchService.getExpiring(days),
});
}
export function useCreateBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBatchDto) => batchService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
},
});
}
export function useUpdateBatchStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: number; status: BatchStatus }) =>
batchService.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batch(id) });
},
});
}
// ============================================
// SERIALS HOOKS
// ============================================
export function useSerials(articleId?: number, status?: SerialStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.serials, articleId, status],
queryFn: () => serialService.getAll(articleId, status),
});
}
export function useSerial(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.serial(id!),
queryFn: () => serialService.getById(id!),
enabled: !!id,
});
}
export function useArticleSerials(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.serials, "article", articleId],
queryFn: () => serialService.getAll(articleId),
enabled: !!articleId,
});
}
export function useCreateSerial() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSerialDto) => serialService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useCreateSerialsBulk() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSerialsBulkDto) => serialService.createBulk(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useRegisterSerialSale() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
customerId,
saleReference,
}: {
id: number;
customerId?: number;
saleReference?: string;
}) => serialService.registerSale(id, customerId, saleReference),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.serial(id),
});
},
});
}
export function useRegisterSerialReturn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
warehouseId,
isDefective,
}: {
id: number;
warehouseId: number;
isDefective: boolean;
}) => serialService.registerReturn(id, warehouseId, isDefective),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.serial(id),
});
},
});
}
// ============================================
// STOCK LEVELS HOOKS
// ============================================
export function useStockLevels(filter?: StockLevelFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, filter],
queryFn: () => stockService.getAll(filter),
});
}
export function useArticleStockLevels(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "article", articleId],
queryFn: () => stockService.getAll({ articleId }),
enabled: !!articleId,
});
}
export function useWarehouseStockLevels(warehouseId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "warehouse", warehouseId],
queryFn: () => stockService.getAll({ warehouseId }),
enabled: !!warehouseId,
});
}
export function useStockValuation(warehouseId?: number) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "valuation", warehouseId],
queryFn: () => stockService.getValuation(warehouseId || 0),
});
}
export function useClosePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (period: number) => stockService.closePeriod(period),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
// ============================================
// INVENTORY HOOKS
// ============================================
export function useInventories(status?: InventoryStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.inventories, status],
queryFn: () => inventoryService.getAll(status),
});
}
export function useInventory(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.inventory(id!),
queryFn: () => inventoryService.getById(id!),
enabled: !!id,
});
}
export function useCreateInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateInventoryCountDto) =>
inventoryService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
},
});
}
export function useStartInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.start(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}
export function useUpdateInventoryLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
inventoryId: _inventoryId,
lineId,
countedQty,
}: {
inventoryId: number;
lineId: number;
countedQty: number;
}) => inventoryService.updateLine(lineId, countedQty),
onSuccess: (_, { inventoryId: invId }) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(invId),
});
},
});
}
export function useCompleteInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.complete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}
export function useConfirmInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.confirm(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
},
});
}
export function useCancelInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}

View File

@@ -0,0 +1,76 @@
// Re-export all hooks from context
export {
useWarehouseContext,
warehouseQueryKeys,
// Articles
useArticles,
useArticle,
useArticleStock,
useCreateArticle,
useUpdateArticle,
useDeleteArticle,
useUploadArticleImage,
// Warehouses
useWarehouses,
useWarehouse,
useCreateWarehouse,
useUpdateWarehouse,
useDeleteWarehouse,
useSetDefaultWarehouse,
// Categories
useCategories,
useCategoryTree,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
// Movements
useMovements,
useMovement,
useCreateInboundMovement,
useCreateOutboundMovement,
useCreateTransferMovement,
useCreateAdjustmentMovement,
useConfirmMovement,
useCancelMovement,
useDeleteMovement,
// Batches
useBatches,
useBatch,
useArticleBatches,
useExpiringBatches,
useCreateBatch,
useUpdateBatchStatus,
// Serials
useSerials,
useSerial,
useArticleSerials,
useCreateSerial,
useCreateSerialsBulk,
useRegisterSerialSale,
useRegisterSerialReturn,
// Stock Levels
useStockLevels,
useArticleStockLevels,
useWarehouseStockLevels,
useStockValuation,
useClosePeriod,
// Inventory
useInventories,
useInventory,
useCreateInventory,
useStartInventory,
useUpdateInventoryLine,
useCompleteInventory,
useConfirmInventory,
useCancelInventory,
} from "../contexts/WarehouseContext";
// Export navigation hook
export { useWarehouseNavigation } from "./useWarehouseNavigation";
// Export calculation hooks
export {
useStockCalculations,
useArticleAvailability,
calculateValuation,
} from "./useStockCalculations";

View File

@@ -0,0 +1,280 @@
import { useMemo } from "react";
import { StockLevelDto, ArticleDto, ValuationMethod } from "../types";
interface StockSummary {
totalQuantity: number;
totalValue: number;
averageCost: number;
articleCount: number;
lowStockCount: number;
outOfStockCount: number;
}
interface ArticleStockInfo {
totalQuantity: number;
totalValue: number;
availableQuantity: number;
reservedQuantity: number;
warehouseBreakdown: {
warehouseId: number;
warehouseName: string;
quantity: number;
value: number;
}[];
}
/**
* Hook for stock calculations and aggregations
*/
export function useStockCalculations(stockLevels: StockLevelDto[] | undefined) {
const summary = useMemo<StockSummary>(() => {
if (!stockLevels || stockLevels.length === 0) {
return {
totalQuantity: 0,
totalValue: 0,
averageCost: 0,
articleCount: 0,
lowStockCount: 0,
outOfStockCount: 0,
};
}
const articleIds = new Set<number>();
let totalQuantity = 0;
let totalValue = 0;
let lowStockCount = 0;
let outOfStockCount = 0;
for (const level of stockLevels) {
articleIds.add(level.articleId);
totalQuantity += level.quantity;
totalValue += level.stockValue || 0;
if (level.quantity <= 0) {
outOfStockCount++;
} else if (level.isLowStock) {
lowStockCount++;
}
}
return {
totalQuantity,
totalValue,
averageCost: totalQuantity > 0 ? totalValue / totalQuantity : 0,
articleCount: articleIds.size,
lowStockCount,
outOfStockCount,
};
}, [stockLevels]);
const getArticleStock = useMemo(() => {
return (articleId: number): ArticleStockInfo => {
if (!stockLevels) {
return {
totalQuantity: 0,
totalValue: 0,
availableQuantity: 0,
reservedQuantity: 0,
warehouseBreakdown: [],
};
}
const articleLevels = stockLevels.filter(
(l) => l.articleId === articleId,
);
let totalQuantity = 0;
let totalValue = 0;
let reservedQuantity = 0;
const warehouseBreakdown: ArticleStockInfo["warehouseBreakdown"] = [];
for (const level of articleLevels) {
totalQuantity += level.quantity;
totalValue += level.stockValue || 0;
reservedQuantity += level.reservedQuantity;
warehouseBreakdown.push({
warehouseId: level.warehouseId,
warehouseName:
level.warehouseName || `Magazzino ${level.warehouseId}`,
quantity: level.quantity,
value: level.stockValue || 0,
});
}
return {
totalQuantity,
totalValue,
availableQuantity: totalQuantity - reservedQuantity,
reservedQuantity,
warehouseBreakdown,
};
};
}, [stockLevels]);
const groupByWarehouse = useMemo(() => {
if (!stockLevels) return new Map<number, StockLevelDto[]>();
const grouped = new Map<number, StockLevelDto[]>();
for (const level of stockLevels) {
const existing = grouped.get(level.warehouseId) || [];
existing.push(level);
grouped.set(level.warehouseId, existing);
}
return grouped;
}, [stockLevels]);
return {
summary,
getArticleStock,
groupByWarehouse,
};
}
/**
* Calculate valuation based on method
*/
export function calculateValuation(
movements: { quantity: number; unitCost: number; date: string }[],
method: ValuationMethod,
targetQuantity: number,
): { totalCost: number; averageCost: number } {
if (targetQuantity <= 0 || movements.length === 0) {
return { totalCost: 0, averageCost: 0 };
}
// Sort movements by date
const sorted = [...movements].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
switch (method) {
case ValuationMethod.WeightedAverage: {
let totalQty = 0;
let totalCost = 0;
for (const m of sorted) {
totalQty += m.quantity;
totalCost += m.quantity * m.unitCost;
}
const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
return {
totalCost: targetQuantity * avgCost,
averageCost: avgCost,
};
}
case ValuationMethod.FIFO: {
// First In, First Out
let remaining = targetQuantity;
let totalCost = 0;
for (const m of sorted) {
if (remaining <= 0) break;
const take = Math.min(remaining, m.quantity);
totalCost += take * m.unitCost;
remaining -= take;
}
return {
totalCost,
averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
};
}
case ValuationMethod.LIFO: {
// Last In, First Out
const reversed = [...sorted].reverse();
let remaining = targetQuantity;
let totalCost = 0;
for (const m of reversed) {
if (remaining <= 0) break;
const take = Math.min(remaining, m.quantity);
totalCost += take * m.unitCost;
remaining -= take;
}
return {
totalCost,
averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
};
}
case ValuationMethod.StandardCost: {
// Use the most recent cost as standard
const lastMovement = sorted[sorted.length - 1];
const standardCost = lastMovement?.unitCost || 0;
return {
totalCost: targetQuantity * standardCost,
averageCost: standardCost,
};
}
case ValuationMethod.SpecificCost: {
// Specific cost requires batch/serial tracking
// For now, fall back to weighted average
let totalQty = 0;
let totalCost = 0;
for (const m of sorted) {
totalQty += m.quantity;
totalCost += m.quantity * m.unitCost;
}
const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
return {
totalCost: targetQuantity * avgCost,
averageCost: avgCost,
};
}
default:
return { totalCost: 0, averageCost: 0 };
}
}
/**
* Hook for article availability check
*/
export function useArticleAvailability(
article: ArticleDto | undefined,
stockLevels: StockLevelDto[] | undefined,
requestedQuantity: number,
warehouseId?: number,
) {
return useMemo(() => {
if (!article || !stockLevels) {
return {
isAvailable: false,
availableQuantity: 0,
shortageQuantity: requestedQuantity,
message: "Dati non disponibili",
};
}
const relevantLevels = warehouseId
? stockLevels.filter(
(l) => l.articleId === article.id && l.warehouseId === warehouseId,
)
: stockLevels.filter((l) => l.articleId === article.id);
const totalAvailable = relevantLevels.reduce(
(sum, l) =>
sum + (l.availableQuantity || l.quantity - l.reservedQuantity),
0,
);
const isAvailable = totalAvailable >= requestedQuantity;
const shortageQuantity = Math.max(0, requestedQuantity - totalAvailable);
let message: string;
if (isAvailable) {
message = `Disponibile: ${totalAvailable} ${article.unitOfMeasure}`;
} else if (totalAvailable > 0) {
message = `Disponibile parzialmente: ${totalAvailable} ${article.unitOfMeasure} (mancano ${shortageQuantity})`;
} else {
message = "Non disponibile";
}
return {
isAvailable,
availableQuantity: totalAvailable,
shortageQuantity,
message,
};
}, [article, stockLevels, requestedQuantity, warehouseId]);
}

View File

@@ -0,0 +1,160 @@
import { useNavigate } from 'react-router-dom';
import { useCallback } from 'react';
/**
* Hook for navigating within the warehouse module
*/
export function useWarehouseNavigation() {
const navigate = useNavigate();
// Articles
const goToArticles = useCallback(() => {
navigate('/warehouse/articles');
}, [navigate]);
const goToArticle = useCallback((id: number) => {
navigate(`/warehouse/articles/${id}`);
}, [navigate]);
const goToNewArticle = useCallback(() => {
navigate('/warehouse/articles/new');
}, [navigate]);
const goToEditArticle = useCallback((id: number) => {
navigate(`/warehouse/articles/${id}/edit`);
}, [navigate]);
// Warehouses
const goToWarehouses = useCallback(() => {
navigate('/warehouse/locations');
}, [navigate]);
const goToWarehouse = useCallback((id: number) => {
navigate(`/warehouse/locations/${id}`);
}, [navigate]);
const goToNewWarehouse = useCallback(() => {
navigate('/warehouse/locations/new');
}, [navigate]);
// Categories
const goToCategories = useCallback(() => {
navigate('/warehouse/categories');
}, [navigate]);
// Movements
const goToMovements = useCallback(() => {
navigate('/warehouse/movements');
}, [navigate]);
const goToMovement = useCallback((id: number) => {
navigate(`/warehouse/movements/${id}`);
}, [navigate]);
const goToNewInbound = useCallback(() => {
navigate('/warehouse/movements/inbound/new');
}, [navigate]);
const goToNewOutbound = useCallback(() => {
navigate('/warehouse/movements/outbound/new');
}, [navigate]);
const goToNewTransfer = useCallback(() => {
navigate('/warehouse/movements/transfer/new');
}, [navigate]);
const goToNewAdjustment = useCallback(() => {
navigate('/warehouse/movements/adjustment/new');
}, [navigate]);
// Batches
const goToBatches = useCallback(() => {
navigate('/warehouse/batches');
}, [navigate]);
const goToBatch = useCallback((id: number) => {
navigate(`/warehouse/batches/${id}`);
}, [navigate]);
const goToNewBatch = useCallback(() => {
navigate('/warehouse/batches/new');
}, [navigate]);
// Serials
const goToSerials = useCallback(() => {
navigate('/warehouse/serials');
}, [navigate]);
const goToSerial = useCallback((id: number) => {
navigate(`/warehouse/serials/${id}`);
}, [navigate]);
const goToNewSerial = useCallback(() => {
navigate('/warehouse/serials/new');
}, [navigate]);
// Stock
const goToStockLevels = useCallback(() => {
navigate('/warehouse/stock');
}, [navigate]);
const goToValuation = useCallback(() => {
navigate('/warehouse/valuation');
}, [navigate]);
// Inventory
const goToInventories = useCallback(() => {
navigate('/warehouse/inventories');
}, [navigate]);
const goToInventory = useCallback((id: number) => {
navigate(`/warehouse/inventories/${id}`);
}, [navigate]);
const goToNewInventory = useCallback(() => {
navigate('/warehouse/inventories/new');
}, [navigate]);
// Dashboard
const goToDashboard = useCallback(() => {
navigate('/warehouse');
}, [navigate]);
return {
// Articles
goToArticles,
goToArticle,
goToNewArticle,
goToEditArticle,
// Warehouses
goToWarehouses,
goToWarehouse,
goToNewWarehouse,
// Categories
goToCategories,
// Movements
goToMovements,
goToMovement,
goToNewInbound,
goToNewOutbound,
goToNewTransfer,
goToNewAdjustment,
// Batches
goToBatches,
goToBatch,
goToNewBatch,
// Serials
goToSerials,
goToSerial,
goToNewSerial,
// Stock
goToStockLevels,
goToValuation,
// Inventory
goToInventories,
goToInventory,
goToNewInventory,
// Dashboard
goToDashboard,
};
}

View File

@@ -0,0 +1,900 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Divider,
Alert,
CircularProgress,
IconButton,
Card,
CardMedia,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
InputAdornment,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Upload as UploadIcon,
Delete as DeleteIcon,
Image as ImageIcon,
} from "@mui/icons-material";
import {
useArticle,
useCreateArticle,
useUpdateArticle,
useUploadArticleImage,
useCategoryTree,
useArticleStockLevels,
useArticleBatches,
useArticleSerials,
} from "../hooks";
import {
ValuationMethod,
StockManagementType,
valuationMethodLabels,
stockManagementTypeLabels,
formatCurrency,
formatQuantity,
formatDate,
CreateArticleDto,
UpdateArticleDto,
StockLevelDto,
BatchDto,
SerialDto,
} from "../types";
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
export default function ArticleFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isNew = !id || id === "new";
const articleId = isNew ? undefined : parseInt(id, 10);
const [tabValue, setTabValue] = useState(0);
const [formData, setFormData] = useState({
code: "",
description: "",
shortDescription: "",
categoryId: undefined as number | undefined,
unitOfMeasure: "PZ",
barcode: "",
minimumStock: 0,
maximumStock: 0,
reorderPoint: 0,
reorderQuantity: 0,
standardCost: 0,
stockManagement: StockManagementType.Standard,
valuationMethod: ValuationMethod.WeightedAverage,
isBatchManaged: false,
isSerialManaged: false,
hasExpiry: false,
expiryWarningDays: 30,
isActive: true,
notes: "",
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: article, isLoading: loadingArticle } = useArticle(articleId);
const { data: categoryTree } = useCategoryTree();
const { data: stockLevels } = useArticleStockLevels(articleId);
const { data: batches } = useArticleBatches(articleId);
const { data: serials } = useArticleSerials(articleId);
const createMutation = useCreateArticle();
const updateMutation = useUpdateArticle();
const uploadImageMutation = useUploadArticleImage();
// Flatten category tree
const flatCategories = React.useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (categories: typeof categoryTree, level = 0) => {
if (!categories) return;
for (const cat of categories) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
// Load article data
useEffect(() => {
if (article) {
setFormData({
code: article.code,
description: article.description,
shortDescription: article.shortDescription || "",
categoryId: article.categoryId,
unitOfMeasure: article.unitOfMeasure,
barcode: article.barcode || "",
minimumStock: article.minimumStock || 0,
maximumStock: article.maximumStock || 0,
reorderPoint: article.reorderPoint || 0,
reorderQuantity: article.reorderQuantity || 0,
standardCost: article.standardCost || 0,
stockManagement: article.stockManagement,
valuationMethod:
article.valuationMethod || ValuationMethod.WeightedAverage,
isBatchManaged: article.isBatchManaged,
isSerialManaged: article.isSerialManaged,
hasExpiry: article.hasExpiry,
expiryWarningDays: article.expiryWarningDays || 30,
isActive: article.isActive,
notes: article.notes || "",
});
if (article.hasImage) {
setImagePreview(`/api/warehouse/articles/${article.id}/image`);
}
}
}, [article]);
const handleChange = (field: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveImage = () => {
setImageFile(null);
setImagePreview(null);
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) {
newErrors.code = "Il codice è obbligatorio";
}
if (!formData.description.trim()) {
newErrors.description = "La descrizione è obbligatoria";
}
if (!formData.unitOfMeasure.trim()) {
newErrors.unitOfMeasure = "L'unità di misura è obbligatoria";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
let savedId: number;
if (isNew) {
const createData: CreateArticleDto = {
code: formData.code,
description: formData.description,
shortDescription: formData.shortDescription || undefined,
categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined,
minimumStock: formData.minimumStock,
maximumStock: formData.maximumStock,
reorderPoint: formData.reorderPoint,
reorderQuantity: formData.reorderQuantity,
standardCost: formData.standardCost,
stockManagement: formData.stockManagement,
valuationMethod: formData.valuationMethod,
isBatchManaged: formData.isBatchManaged,
isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays,
notes: formData.notes || undefined,
};
const result = await createMutation.mutateAsync(createData);
savedId = result.id;
} else {
const updateData: UpdateArticleDto = {
code: formData.code,
description: formData.description,
shortDescription: formData.shortDescription || undefined,
categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined,
minimumStock: formData.minimumStock,
maximumStock: formData.maximumStock,
reorderPoint: formData.reorderPoint,
reorderQuantity: formData.reorderQuantity,
standardCost: formData.standardCost,
stockManagement: formData.stockManagement,
valuationMethod: formData.valuationMethod,
isBatchManaged: formData.isBatchManaged,
isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays,
isActive: formData.isActive,
notes: formData.notes || undefined,
};
await updateMutation.mutateAsync({ id: articleId!, data: updateData });
savedId = articleId!;
}
// Upload image if selected
if (imageFile) {
await uploadImageMutation.mutateAsync({ id: savedId, file: imageFile });
}
navigate(`/warehouse/articles/${savedId}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending =
createMutation.isPending ||
updateMutation.isPending ||
uploadImageMutation.isPending;
if (!isNew && loadingArticle) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={400}
>
<CircularProgress />
</Box>
);
}
return (
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h5" fontWeight="bold">
{isNew ? "Nuovo Articolo" : `Articolo: ${article?.code}`}
</Typography>
</Box>
{(createMutation.error || updateMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore durante il salvataggio:{" "}
{((createMutation.error || updateMutation.error) as Error).message}
</Alert>
)}
{!isNew && (
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Dati Generali" />
<Tab label="Giacenze" />
{article?.isBatchManaged && <Tab label="Lotti" />}
{article?.isSerialManaged && <Tab label="Matricole" />}
</Tabs>
</Box>
)}
<TabPanel value={tabValue} index={0}>
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
{/* Left Column - Form */}
<Grid size={{ xs: 12, md: 8 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Informazioni Base
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Codice"
value={formData.code}
onChange={(e) => handleChange("code", e.target.value)}
error={!!errors.code}
helperText={errors.code}
required
disabled={!isNew}
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
fullWidth
label="Descrizione"
value={formData.description}
onChange={(e) =>
handleChange("description", e.target.value)
}
error={!!errors.description}
helperText={errors.description}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Descrizione Breve"
value={formData.shortDescription}
onChange={(e) =>
handleChange("shortDescription", e.target.value)
}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel>Categoria</InputLabel>
<Select
value={formData.categoryId || ""}
label="Categoria"
onChange={(e) =>
handleChange(
"categoryId",
e.target.value || undefined,
)
}
>
<MenuItem value="">
<em>Nessuna</em>
</MenuItem>
{flatCategories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{"—".repeat(cat.level)} {cat.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Unità di Misura"
value={formData.unitOfMeasure}
onChange={(e) =>
handleChange("unitOfMeasure", e.target.value)
}
error={!!errors.unitOfMeasure}
helperText={errors.unitOfMeasure}
required
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Codice a Barre"
value={formData.barcode}
onChange={(e) => handleChange("barcode", e.target.value)}
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Livelli di Scorta
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Scorta Minima"
type="number"
value={formData.minimumStock}
onChange={(e) =>
handleChange(
"minimumStock",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Scorta Massima"
type="number"
value={formData.maximumStock}
onChange={(e) =>
handleChange(
"maximumStock",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Punto di Riordino"
type="number"
value={formData.reorderPoint}
onChange={(e) =>
handleChange(
"reorderPoint",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Quantità Riordino"
type="number"
value={formData.reorderQuantity}
onChange={(e) =>
handleChange(
"reorderQuantity",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Costi e Valorizzazione
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Costo Standard"
type="number"
value={formData.standardCost}
onChange={(e) =>
handleChange(
"standardCost",
parseFloat(e.target.value) || 0,
)
}
InputProps={{
startAdornment: (
<InputAdornment position="start"></InputAdornment>
),
inputProps: { min: 0, step: 0.01 },
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControl fullWidth>
<InputLabel>Gestione Stock</InputLabel>
<Select
value={formData.stockManagement}
label="Gestione Stock"
onChange={(e) =>
handleChange("stockManagement", e.target.value)
}
>
{Object.entries(stockManagementTypeLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControl fullWidth>
<InputLabel>Metodo di Valorizzazione</InputLabel>
<Select
value={formData.valuationMethod}
label="Metodo di Valorizzazione"
onChange={(e) =>
handleChange("valuationMethod", e.target.value)
}
>
{Object.entries(valuationMethodLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Tracciabilità
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.isBatchManaged}
onChange={(e) =>
handleChange("isBatchManaged", e.target.checked)
}
disabled={!isNew && article?.isBatchManaged}
/>
}
label="Gestione Lotti"
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.isSerialManaged}
onChange={(e) =>
handleChange("isSerialManaged", e.target.checked)
}
disabled={!isNew && article?.isSerialManaged}
/>
}
label="Gestione Matricole"
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.hasExpiry}
onChange={(e) =>
handleChange("hasExpiry", e.target.checked)
}
/>
}
label="Gestione Scadenza"
/>
</Grid>
<Grid size={12}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) =>
handleChange("isActive", e.target.checked)
}
/>
}
label="Articolo Attivo"
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<TextField
fullWidth
label="Note"
value={formData.notes}
onChange={(e) => handleChange("notes", e.target.value)}
multiline
rows={3}
/>
</Paper>
</Grid>
{/* Right Column - Image */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Immagine
</Typography>
{imagePreview ? (
<Card sx={{ mb: 2 }}>
<CardMedia
component="img"
image={imagePreview}
alt="Anteprima"
sx={{
height: 200,
objectFit: "contain",
bgcolor: "grey.100",
}}
/>
</Card>
) : (
<Box
sx={{
height: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
borderRadius: 1,
mb: 2,
}}
>
<ImageIcon sx={{ fontSize: 64, color: "grey.400" }} />
</Box>
)}
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
component="label"
startIcon={<UploadIcon />}
fullWidth
>
Carica
<input
type="file"
hidden
accept="image/*"
onChange={handleImageChange}
/>
</Button>
{imagePreview && (
<IconButton color="error" onClick={handleRemoveImage}>
<DeleteIcon />
</IconButton>
)}
</Box>
</Paper>
{/* Summary Card (only for existing articles) */}
{!isNew && article && (
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" gutterBottom>
Riepilogo
</Typography>
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<Box
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography color="text.secondary">
Costo Medio:
</Typography>
<Typography fontWeight="medium">
{formatCurrency(article.weightedAverageCost || 0)}
</Typography>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography color="text.secondary">
Ultimo Acquisto:
</Typography>
<Typography fontWeight="medium">
{formatCurrency(article.lastPurchaseCost || 0)}
</Typography>
</Box>
</Box>
</Paper>
)}
</Grid>
{/* Submit Button */}
<Grid size={12}>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
type="submit"
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
disabled={isPending}
>
{isPending ? "Salvataggio..." : "Salva"}
</Button>
</Box>
</Grid>
</Grid>
</form>
</TabPanel>
{/* Stock Levels Tab */}
<TabPanel value={tabValue} index={1}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Giacenze per Magazzino
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Magazzino</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell align="right">Riservata</TableCell>
<TableCell align="right">Disponibile</TableCell>
<TableCell align="right">Valore</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!stockLevels || stockLevels.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography color="text.secondary">
Nessuna giacenza
</Typography>
</TableCell>
</TableRow>
) : (
stockLevels.map((level: StockLevelDto) => (
<TableRow key={level.id}>
<TableCell>{level.warehouseName}</TableCell>
<TableCell align="right">
{formatQuantity(level.quantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatQuantity(level.reservedQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatQuantity(level.availableQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatCurrency(level.stockValue)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
{/* Batches Tab */}
{article?.isBatchManaged && (
<TabPanel value={tabValue} index={2}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Lotti
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Numero Lotto</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell>Data Scadenza</TableCell>
<TableCell>Stato</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!batches || batches.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography color="text.secondary">
Nessun lotto
</Typography>
</TableCell>
</TableRow>
) : (
batches.map((batch: BatchDto) => (
<TableRow key={batch.id}>
<TableCell>{batch.batchNumber}</TableCell>
<TableCell align="right">
{formatQuantity(batch.currentQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell>
{batch.expiryDate
? formatDate(batch.expiryDate)
: "-"}
</TableCell>
<TableCell>
<Chip
label={batch.isExpired ? "Scaduto" : "Disponibile"}
size="small"
color={batch.isExpired ? "error" : "success"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
)}
{/* Serials Tab */}
{article?.isSerialManaged && (
<TabPanel value={tabValue} index={article?.isBatchManaged ? 3 : 2}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Matricole
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Matricola</TableCell>
<TableCell>Magazzino</TableCell>
<TableCell>Lotto</TableCell>
<TableCell>Stato</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!serials || serials.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography color="text.secondary">
Nessuna matricola
</Typography>
</TableCell>
</TableRow>
) : (
serials.map((serial: SerialDto) => (
<TableRow key={serial.id}>
<TableCell>{serial.serialNumber}</TableCell>
<TableCell>
{serial.currentWarehouseName || "-"}
</TableCell>
<TableCell>{serial.batchNumber || "-"}</TableCell>
<TableCell>
<Chip
label={
serial.status === 0
? "Disponibile"
: "Non disponibile"
}
size="small"
color={serial.status === 0 ? "success" : "default"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
)}
</Box>
);
}

View File

@@ -0,0 +1,523 @@
import React, { useState, useMemo } from "react";
import {
Box,
Paper,
Typography,
Button,
TextField,
InputAdornment,
IconButton,
Chip,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Tooltip,
Card,
CardContent,
CardMedia,
CardActions,
Grid,
ToggleButton,
ToggleButtonGroup,
FormControl,
InputLabel,
Select,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
Skeleton,
} from "@mui/material";
import {
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
ViewList as ViewListIcon,
ViewModule as ViewModuleIcon,
MoreVert as MoreVertIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Inventory as InventoryIcon,
FilterList as FilterListIcon,
Image as ImageIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { useArticles, useDeleteArticle, useCategoryTree } from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import { ArticleDto, formatCurrency } from "../types";
type ViewMode = "list" | "grid";
export default function ArticlesPage() {
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [search, setSearch] = useState("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [showInactive, setShowInactive] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [articleToDelete, setArticleToDelete] = useState<ArticleDto | null>(
null,
);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuArticle, setMenuArticle] = useState<ArticleDto | null>(null);
const nav = useWarehouseNavigation();
const {
data: articles,
isLoading,
error,
} = useArticles({
categoryId: categoryId || undefined,
search: search || undefined,
isActive: showInactive ? undefined : true,
});
const { data: categoryTree } = useCategoryTree();
const deleteMutation = useDeleteArticle();
// Flatten category tree for select
const flatCategories = useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (categories: typeof categoryTree, level = 0) => {
if (!categories) return;
for (const cat of categories) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
article: ArticleDto,
) => {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
setMenuArticle(article);
};
const handleMenuClose = () => {
setMenuAnchor(null);
setMenuArticle(null);
};
const handleEdit = () => {
if (menuArticle) {
nav.goToEditArticle(menuArticle.id);
}
handleMenuClose();
};
const handleDeleteClick = () => {
setArticleToDelete(menuArticle);
setDeleteDialogOpen(true);
handleMenuClose();
};
const handleDeleteConfirm = async () => {
if (articleToDelete) {
await deleteMutation.mutateAsync(articleToDelete.id);
setDeleteDialogOpen(false);
setArticleToDelete(null);
}
};
const handleViewStock = () => {
if (menuArticle) {
nav.goToArticle(menuArticle.id);
}
handleMenuClose();
};
const columns: GridColDef[] = [
{
field: "hasImage",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
{params.row.hasImage ? (
<img
src={`/api/warehouse/articles/${params.row.id}/image`}
alt={params.row.description}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<ImageIcon sx={{ color: "grey.400" }} />
)}
</Box>
),
},
{
field: "code",
headerName: "Codice",
width: 120,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
),
},
{
field: "description",
headerName: "Descrizione",
flex: 1,
minWidth: 200,
},
{
field: "categoryName",
headerName: "Categoria",
width: 150,
},
{
field: "unitOfMeasure",
headerName: "U.M.",
width: 80,
align: "center",
},
{
field: "weightedAverageCost",
headerName: "Costo Medio",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<ArticleDto>) =>
formatCurrency(params.value || 0),
},
{
field: "isActive",
headerName: "Stato",
width: 100,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Chip
label={params.value ? "Attivo" : "Inattivo"}
size="small"
color={params.value ? "success" : "default"}
/>
),
},
{
field: "actions",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<IconButton size="small" onClick={(e) => handleMenuOpen(e, params.row)}>
<MoreVertIcon />
</IconButton>
),
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento degli articoli: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Anagrafica Articoli
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={nav.goToNewArticle}
>
Nuovo Articolo
</Button>
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca per codice o descrizione..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Categoria</InputLabel>
<Select
value={categoryId}
label="Categoria"
onChange={(e) => setCategoryId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutte</em>
</MenuItem>
{flatCategories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{"—".repeat(cat.level)} {cat.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Button
variant={showInactive ? "contained" : "outlined"}
size="small"
startIcon={<FilterListIcon />}
onClick={() => setShowInactive(!showInactive)}
fullWidth
>
{showInactive ? "Mostra Tutti" : "Solo Attivi"}
</Button>
</Grid>
<Grid
size={{ xs: 12, sm: 6, md: 3 }}
sx={{ display: "flex", justifyContent: "flex-end" }}
>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, value) => value && setViewMode(value)}
size="small"
>
<ToggleButton value="list">
<Tooltip title="Vista Lista">
<ViewListIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="grid">
<Tooltip title="Vista Griglia">
<ViewModuleIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Paper>
{/* Content */}
{viewMode === "list" ? (
<Paper sx={{ height: 600 }}>
<DataGrid
rows={articles || []}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "code", sort: "asc" }] },
}}
onRowDoubleClick={(params) => nav.goToArticle(params.row.id)}
disableRowSelectionOnClick
sx={{
"& .MuiDataGrid-row:hover": {
cursor: "pointer",
},
}}
/>
</Paper>
) : (
<Grid container spacing={2}>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card>
<Skeleton variant="rectangular" height={140} />
<CardContent>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" />
</CardContent>
</Card>
</Grid>
))
) : articles?.length === 0 ? (
<Grid size={12}>
<Paper sx={{ p: 4, textAlign: "center" }}>
<InventoryIcon
sx={{ fontSize: 48, color: "grey.400", mb: 2 }}
/>
<Typography color="text.secondary">
Nessun articolo trovato
</Typography>
</Paper>
</Grid>
) : (
articles?.map((article) => (
<Grid key={article.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
cursor: "pointer",
"&:hover": { boxShadow: 4 },
}}
onClick={() => nav.goToArticle(article.id)}
>
{article.hasImage ? (
<CardMedia
component="img"
height="140"
image={`/api/warehouse/articles/${article.id}/image`}
alt={article.description}
sx={{ objectFit: "cover" }}
/>
) : (
<Box
sx={{
height: 140,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
<ImageIcon sx={{ fontSize: 48, color: "grey.400" }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="caption" color="text.secondary">
{article.code}
</Typography>
<Typography
variant="subtitle1"
fontWeight="medium"
gutterBottom
noWrap
>
{article.description}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{article.categoryName && (
<Chip label={article.categoryName} size="small" />
)}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
nav.goToEditArticle(article.id);
}}
>
Modifica
</Button>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, article)}
sx={{ ml: "auto" }}
>
<MoreVertIcon />
</IconButton>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
)}
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleEdit}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Modifica</ListItemText>
</MenuItem>
<MenuItem onClick={handleViewStock}>
<ListItemIcon>
<InventoryIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Visualizza Giacenze</ListItemText>
</MenuItem>
<MenuItem onClick={handleDeleteClick} sx={{ color: "error.main" }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Elimina</ListItemText>
</MenuItem>
</Menu>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare l'articolo{" "}
<strong>
{articleToDelete?.code} - {articleToDelete?.description}
</strong>
?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,454 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useCreateInboundMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateMovementDto, formatCurrency } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
unitCost: number;
notes?: string;
}
export default function InboundMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const createMutation = useCreateInboundMovement();
const confirmMutation = useConfirmMovement();
// Set default warehouse
React.useEffect(() => {
if (warehouses && warehouseId === "") {
const defaultWarehouse = warehouses.find((w) => w.isDefault);
if (defaultWarehouse) {
setWarehouseId(defaultWarehouse.id);
}
}
}, [warehouses, warehouseId]);
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
// Auto-fill unit cost from article
if (field === "article" && value) {
const article = value as ArticleDto;
updated.unitCost =
article.lastPurchaseCost || article.weightedAverageCost || 0;
}
return updated;
}
return l;
}),
);
};
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const totalValue = lines.reduce(
(sum, l) => sum + (l.quantity || 0) * (l.unitCost || 0),
0,
);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!warehouseId) {
newErrors.warehouseId = "Seleziona un magazzino";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateMovementDto = {
warehouseId: warehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
unitCost: l.unitCost,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box>
<Typography variant="h5" fontWeight="bold">
Nuovo Carico
</Typography>
<Typography variant="body2" color="text.secondary">
Movimento di entrata merce in magazzino
</Typography>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{/* Form Header */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Movimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data Movimento"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.warehouseId}>
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number)}
>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
{w.isDefault && (
<Chip label="Default" size="small" sx={{ ml: 1 }} />
)}
</MenuItem>
))}
</Select>
{errors.warehouseId && (
<Typography variant="caption" color="error">
{errors.warehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Numero Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="DDT, Fattura, etc."
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
placeholder="Ordine, Fornitore, etc."
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
rows={2}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Righe Movimento</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi Riga
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ width: "45%" }}>Articolo</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Quantità
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Costo Unitario
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Totale
</TableCell>
<TableCell sx={{ width: 60 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id}>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, value) =>
handleLineChange(line.id, "article", value)
}
options={articles || []}
getOptionLabel={(option) =>
`${option.code} - ${option.description}`
}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Seleziona articolo"
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.unitCost}
onChange={(e) =>
handleLineChange(
line.id,
"unitCost",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
startAdornment: (
<InputAdornment position="start">
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell align="right">
<Typography fontWeight="medium">
{formatCurrency(line.quantity * line.unitCost)}
</Typography>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
{/* Totals */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 4 }}>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Quantità
</Typography>
<Typography variant="h6">{totalQuantity.toFixed(2)}</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Valore
</Typography>
<Typography variant="h6">{formatCurrency(totalValue)}</Typography>
</Box>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending}
color="success"
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,650 @@
import React, { useState } from "react";
import {
Box,
Paper,
Typography,
Button,
TextField,
InputAdornment,
IconButton,
Chip,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
FormControl,
InputLabel,
Select,
Grid,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
} from "@mui/material";
import {
Search as SearchIcon,
Clear as ClearIcon,
MoreVert as MoreVertIcon,
Visibility as ViewIcon,
Check as ConfirmIcon,
Close as CancelIcon,
Delete as DeleteIcon,
Add as AddIcon,
Download as DownloadIcon,
Upload as UploadIcon,
SwapHoriz as TransferIcon,
Build as AdjustmentIcon,
FilterList as FilterIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useMovements,
useWarehouses,
useConfirmMovement,
useCancelMovement,
useDeleteMovement,
} from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import {
MovementDto,
MovementType,
MovementStatus,
movementTypeLabels,
movementStatusLabels,
getMovementTypeColor,
getMovementStatusColor,
formatDate,
} from "../types";
export default function MovementsPage() {
const [search, setSearch] = useState("");
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [movementType, setMovementType] = useState<MovementType | "">("");
const [status, setStatus] = useState<MovementStatus | "">("");
const [dateFrom, setDateFrom] = useState<Dayjs | null>(null);
const [dateTo, setDateTo] = useState<Dayjs | null>(null);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuMovement, setMenuMovement] = useState<MovementDto | null>(null);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [speedDialOpen, setSpeedDialOpen] = useState(false);
const nav = useWarehouseNavigation();
const {
data: movements,
isLoading,
error,
} = useMovements({
warehouseId: warehouseId || undefined,
type: movementType !== "" ? movementType : undefined,
status: status !== "" ? status : undefined,
dateFrom: dateFrom?.format("YYYY-MM-DD"),
dateTo: dateTo?.format("YYYY-MM-DD"),
});
const { data: warehouses } = useWarehouses();
const confirmMutation = useConfirmMovement();
const cancelMutation = useCancelMovement();
const deleteMutation = useDeleteMovement();
// Filter movements by search
const filteredMovements = React.useMemo(() => {
if (!movements) return [];
if (!search) return movements;
const lower = search.toLowerCase();
return movements.filter(
(m) =>
m.documentNumber?.toLowerCase().includes(lower) ||
m.externalReference?.toLowerCase().includes(lower) ||
m.notes?.toLowerCase().includes(lower),
);
}, [movements, search]);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
movement: MovementDto,
) => {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
setMenuMovement(movement);
};
const handleMenuClose = () => {
setMenuAnchor(null);
setMenuMovement(null);
};
const handleView = () => {
if (menuMovement) {
nav.goToMovement(menuMovement.id);
}
handleMenuClose();
};
const handleConfirmClick = () => {
setConfirmDialogOpen(true);
setMenuAnchor(null);
};
const handleCancelClick = () => {
setCancelDialogOpen(true);
setMenuAnchor(null);
};
const handleDeleteClick = () => {
setDeleteDialogOpen(true);
setMenuAnchor(null);
};
const handleConfirmMovement = async () => {
if (menuMovement) {
await confirmMutation.mutateAsync(menuMovement.id);
setConfirmDialogOpen(false);
setMenuMovement(null);
}
};
const handleCancelMovement = async () => {
if (menuMovement) {
await cancelMutation.mutateAsync(menuMovement.id);
setCancelDialogOpen(false);
setMenuMovement(null);
}
};
const handleDeleteMovement = async () => {
if (menuMovement) {
await deleteMutation.mutateAsync(menuMovement.id);
setDeleteDialogOpen(false);
setMenuMovement(null);
}
};
const clearFilters = () => {
setSearch("");
setWarehouseId("");
setMovementType("");
setStatus("");
setDateFrom(null);
setDateTo(null);
};
const columns: GridColDef<MovementDto>[] = [
{
field: "documentNumber",
headerName: "Documento",
width: 140,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value || "-"}
</Typography>
),
},
{
field: "movementDate",
headerName: "Data",
width: 110,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
formatDate(params.value),
},
{
field: "type",
headerName: "Tipo",
width: 130,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Chip
label={movementTypeLabels[params.value as MovementType]}
size="small"
color={
getMovementTypeColor(params.value as MovementType) as
| "success"
| "error"
| "info"
| "warning"
| "default"
}
/>
),
},
{
field: "status",
headerName: "Stato",
width: 120,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Chip
label={movementStatusLabels[params.value as MovementStatus]}
size="small"
color={
getMovementStatusColor(params.value as MovementStatus) as
| "success"
| "error"
| "warning"
| "default"
}
variant={
params.value === MovementStatus.Draft ? "outlined" : "filled"
}
/>
),
},
{
field: "sourceWarehouseName",
headerName: "Magazzino",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.row.sourceWarehouseName ||
params.row.destinationWarehouseName ||
"-",
},
{
field: "destinationWarehouseName",
headerName: "Destinazione",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) => {
// Show destination only for transfers
if (params.row.type === MovementType.Transfer) {
return params.row.destinationWarehouseName || "-";
}
return "-";
},
},
{
field: "reasonDescription",
headerName: "Causale",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value || "-",
},
{
field: "lineCount",
headerName: "Righe",
width: 80,
align: "center",
},
{
field: "totalValue",
headerName: "Valore",
width: 100,
align: "right",
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value != null
? new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
}).format(params.value)
: "-",
},
{
field: "externalReference",
headerName: "Riferimento",
width: 140,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value || "-",
},
{
field: "actions",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<IconButton size="small" onClick={(e) => handleMenuOpen(e, params.row)}>
<MoreVertIcon />
</IconButton>
),
},
];
const speedDialActions = [
{ icon: <DownloadIcon />, name: "Carico", action: nav.goToNewInbound },
{ icon: <UploadIcon />, name: "Scarico", action: nav.goToNewOutbound },
{
icon: <TransferIcon />,
name: "Trasferimento",
action: nav.goToNewTransfer,
},
{
icon: <AdjustmentIcon />,
name: "Rettifica",
action: nav.goToNewAdjustment,
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento dei movimenti: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Movimenti di Magazzino
</Typography>
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca documento, riferimento..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) =>
setWarehouseId(e.target.value as number | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Tipo</InputLabel>
<Select
value={movementType}
label="Tipo"
onChange={(e) =>
setMovementType(e.target.value as MovementType | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{Object.entries(movementTypeLabels).map(([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Stato</InputLabel>
<Select
value={status}
label="Stato"
onChange={(e) =>
setStatus(e.target.value as MovementStatus | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{Object.entries(movementStatusLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 1.5 }}>
<DatePicker
label="Da"
value={dateFrom}
onChange={setDateFrom}
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 1.5 }}>
<DatePicker
label="A"
value={dateTo}
onChange={setDateTo}
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Grid>
{(search ||
warehouseId ||
movementType !== "" ||
status !== "" ||
dateFrom ||
dateTo) && (
<Grid size="auto">
<Button
size="small"
startIcon={<FilterIcon />}
onClick={clearFilters}
>
Reset
</Button>
</Grid>
)}
</Grid>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600 }}>
<DataGrid
rows={filteredMovements}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "movementDate", sort: "desc" }] },
}}
onRowDoubleClick={(params) => nav.goToMovement(params.row.id)}
disableRowSelectionOnClick
sx={{
"& .MuiDataGrid-row:hover": {
cursor: "pointer",
},
}}
/>
</Paper>
{/* Speed Dial for New Movements */}
<SpeedDial
ariaLabel="Nuovo Movimento"
sx={{ position: "fixed", bottom: 24, right: 24 }}
icon={<SpeedDialIcon openIcon={<AddIcon />} />}
open={speedDialOpen}
onOpen={() => setSpeedDialOpen(true)}
onClose={() => setSpeedDialOpen(false)}
>
{speedDialActions.map((action) => (
<SpeedDialAction
key={action.name}
icon={action.icon}
tooltipTitle={action.name}
tooltipOpen
onClick={() => {
setSpeedDialOpen(false);
action.action();
}}
/>
))}
</SpeedDial>
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleView}>
<ListItemIcon>
<ViewIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Visualizza</ListItemText>
</MenuItem>
{menuMovement?.status === MovementStatus.Draft && (
<>
<MenuItem onClick={handleConfirmClick}>
<ListItemIcon>
<ConfirmIcon fontSize="small" color="success" />
</ListItemIcon>
<ListItemText>Conferma</ListItemText>
</MenuItem>
<MenuItem onClick={handleCancelClick}>
<ListItemIcon>
<CancelIcon fontSize="small" color="warning" />
</ListItemIcon>
<ListItemText>Annulla</ListItemText>
</MenuItem>
<MenuItem
onClick={handleDeleteClick}
sx={{ color: "error.main" }}
>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Elimina</ListItemText>
</MenuItem>
</>
)}
</Menu>
{/* Confirm Movement Dialog */}
<Dialog
open={confirmDialogOpen}
onClose={() => setConfirmDialogOpen(false)}
>
<DialogTitle>Conferma Movimento</DialogTitle>
<DialogContent>
<Typography>
Confermare il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Le giacenze verranno aggiornate e il movimento non potrà più
essere modificato.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleConfirmMovement}
color="success"
variant="contained"
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? "Conferma..." : "Conferma"}
</Button>
</DialogActions>
</Dialog>
{/* Cancel Movement Dialog */}
<Dialog
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
>
<DialogTitle>Annulla Movimento</DialogTitle>
<DialogContent>
<Typography>
Annullare il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Il movimento verrà marcato come annullato ma non eliminato.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setCancelDialogOpen(false)}>Indietro</Button>
<Button
onClick={handleCancelMovement}
color="warning"
variant="contained"
disabled={cancelMutation.isPending}
>
{cancelMutation.isPending
? "Annullamento..."
: "Annulla Movimento"}
</Button>
</DialogActions>
</Dialog>
{/* Delete Movement Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Elimina Movimento</DialogTitle>
<DialogContent>
<Typography>
Eliminare definitivamente il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteMovement}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,481 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
Tooltip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
Warning as WarningIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useStockLevels,
useCreateOutboundMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateMovementDto, formatQuantity } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
notes?: string;
availableQty?: number;
}
export default function OutboundMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const { data: stockLevels } = useStockLevels({
warehouseId: warehouseId || undefined,
});
const createMutation = useCreateOutboundMovement();
const confirmMutation = useConfirmMovement();
// Set default warehouse
React.useEffect(() => {
if (warehouses && warehouseId === "") {
const defaultWarehouse = warehouses.find((w) => w.isDefault);
if (defaultWarehouse) {
setWarehouseId(defaultWarehouse.id);
}
}
}, [warehouses, warehouseId]);
// Get available quantity for an article
const getAvailableQty = (articleId: number): number => {
if (!stockLevels) return 0;
const level = stockLevels.find((l) => l.articleId === articleId);
return level ? level.availableQuantity : 0;
};
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
// Update available qty when article changes
if (field === "article" && value) {
const article = value as ArticleDto;
updated.availableQty = getAvailableQty(article.id);
}
return updated;
}
return l;
}),
);
};
// Check for stock issues
const hasStockIssues = lines.some(
(l) =>
l.article && l.availableQty !== undefined && l.quantity > l.availableQty,
);
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!warehouseId) {
newErrors.warehouseId = "Seleziona un magazzino";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateMovementDto = {
warehouseId: warehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box>
<Typography variant="h5" fontWeight="bold">
Nuovo Scarico
</Typography>
<Typography variant="body2" color="text.secondary">
Movimento di uscita merce da magazzino
</Typography>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{hasStockIssues && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
Attenzione: alcune righe superano la disponibilità in magazzino
</Alert>
)}
{/* Form Header */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Movimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data Movimento"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.warehouseId}>
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number)}
>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
{w.isDefault && (
<Chip label="Default" size="small" sx={{ ml: 1 }} />
)}
</MenuItem>
))}
</Select>
{errors.warehouseId && (
<Typography variant="caption" color="error">
{errors.warehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Numero Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="DDT, Bolla, etc."
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
placeholder="Ordine, Cliente, etc."
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
rows={2}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Righe Movimento</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi Riga
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ width: "40%" }}>Articolo</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Disponibile
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Quantità
</TableCell>
<TableCell sx={{ width: "20%" }}>Note</TableCell>
<TableCell sx={{ width: 60 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const isOverStock =
line.article &&
line.availableQty !== undefined &&
line.quantity > line.availableQty;
return (
<TableRow
key={line.id}
sx={isOverStock ? { bgcolor: "warning.light" } : {}}
>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, value) =>
handleLineChange(line.id, "article", value)
}
options={articles || []}
getOptionLabel={(option) =>
`${option.code} - ${option.description}`
}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Seleziona articolo"
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
/>
</TableCell>
<TableCell align="right">
{line.article ? (
<Chip
label={`${formatQuantity(line.availableQty || 0)} ${line.article.unitOfMeasure}`}
size="small"
color={
(line.availableQty || 0) <= 0
? "error"
: "default"
}
/>
) : (
"-"
)}
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
error={isOverStock ?? undefined}
fullWidth
/>
{isOverStock && (
<Tooltip title="Quantità superiore alla disponibilità">
<WarningIcon
color="warning"
sx={{ fontSize: 16, ml: 1 }}
/>
</Tooltip>
)}
</Box>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="Note"
value={line.notes || ""}
onChange={(e) =>
handleLineChange(line.id, "notes", e.target.value)
}
fullWidth
/>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
{/* Totals */}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Quantità
</Typography>
<Typography variant="h6">{totalQuantity.toFixed(2)}</Typography>
</Box>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending || hasStockIssues}
color="success"
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,355 @@
import React, { useState } from "react";
import {
Box,
Paper,
Typography,
TextField,
InputAdornment,
IconButton,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Alert,
Chip,
FormControlLabel,
Switch,
Button,
Card,
CardContent,
} from "@mui/material";
import {
Search as SearchIcon,
Clear as ClearIcon,
Warning as WarningIcon,
TrendingUp as TrendingUpIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { useStockLevels, useWarehouses, useCategoryTree } from "../hooks";
import { useStockCalculations } from "../hooks/useStockCalculations";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import { StockLevelDto, formatCurrency, formatQuantity } from "../types";
export default function StockLevelsPage() {
const [search, setSearch] = useState("");
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [lowStockOnly, setLowStockOnly] = useState(false);
const nav = useWarehouseNavigation();
const {
data: stockLevels,
isLoading,
error,
} = useStockLevels({
warehouseId: warehouseId || undefined,
categoryId: categoryId || undefined,
onlyLowStock: lowStockOnly || undefined,
});
const { data: warehouses } = useWarehouses();
const { data: categoryTree } = useCategoryTree();
const { summary } = useStockCalculations(stockLevels);
// Flatten categories
const flatCategories = React.useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (cats: typeof categoryTree, level = 0) => {
if (!cats) return;
for (const cat of cats) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
// Filter by search
const filteredLevels = React.useMemo(() => {
if (!stockLevels) return [];
if (!search) return stockLevels;
const lower = search.toLowerCase();
return stockLevels.filter(
(l) =>
l.articleCode?.toLowerCase().includes(lower) ||
l.articleDescription?.toLowerCase().includes(lower),
);
}, [stockLevels, search]);
const columns: GridColDef[] = [
{
field: "articleCode",
headerName: "Codice",
width: 120,
renderCell: (params: GridRenderCellParams<StockLevelDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
),
},
{
field: "articleDescription",
headerName: "Articolo",
flex: 1,
minWidth: 200,
},
{
field: "warehouseName",
headerName: "Magazzino",
width: 150,
},
{
field: "categoryName",
headerName: "Categoria",
width: 140,
},
{
field: "quantity",
headerName: "Giacenza",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => {
const qty = params.value || 0;
const isLow = params.row.isLowStock;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{isLow && <WarningIcon color="warning" sx={{ fontSize: 16 }} />}
<Chip
label={formatQuantity(qty)}
size="small"
color={qty <= 0 ? "error" : isLow ? "warning" : "default"}
/>
</Box>
);
},
},
{
field: "reservedQuantity",
headerName: "Riservata",
width: 100,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) =>
formatQuantity(params.value || 0),
},
{
field: "availableQuantity",
headerName: "Disponibile",
width: 110,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => {
const available =
params.row.availableQuantity ||
params.row.quantity - params.row.reservedQuantity;
return (
<Typography
fontWeight="medium"
color={available <= 0 ? "error.main" : "text.primary"}
>
{formatQuantity(available)}
</Typography>
);
},
},
{
field: "unitCost",
headerName: "Costo Medio",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) =>
formatCurrency(params.value || 0),
},
{
field: "stockValue",
headerName: "Valore",
width: 130,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => (
<Typography fontWeight="medium">
{formatCurrency(params.value || 0)}
</Typography>
),
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">Errore: {(error as Error).message}</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Giacenze di Magazzino
</Typography>
<Button
variant="outlined"
startIcon={<TrendingUpIcon />}
onClick={nav.goToValuation}
>
Valorizzazione
</Button>
</Box>
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Articoli
</Typography>
<Typography variant="h5" fontWeight="bold">
{summary.articleCount}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Quantità Totale
</Typography>
<Typography variant="h5" fontWeight="bold">
{formatQuantity(summary.totalQuantity)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Valore Totale
</Typography>
<Typography variant="h5" fontWeight="bold" color="success.main">
{formatCurrency(summary.totalValue)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Sotto Scorta
</Typography>
<Typography variant="h5" fontWeight="bold" color="warning.main">
{summary.lowStockCount + summary.outOfStockCount}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca articolo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Categoria</InputLabel>
<Select
value={categoryId}
label="Categoria"
onChange={(e) => setCategoryId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutte</em>
</MenuItem>
{flatCategories.map((c) => (
<MenuItem key={c.id} value={c.id}>
{"—".repeat(c.level)} {c.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControlLabel
control={
<Switch
checked={lowStockOnly}
onChange={(e) => setLowStockOnly(e.target.checked)}
/>
}
label="Solo sotto scorta"
/>
</Grid>
</Grid>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 500 }}>
<DataGrid
rows={filteredLevels}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "articleCode", sort: "asc" }] },
}}
onRowDoubleClick={(params) => nav.goToArticle(params.row.articleId)}
disableRowSelectionOnClick
/>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,447 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
SwapHoriz as TransferIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useStockLevels,
useCreateTransferMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateTransferDto, formatQuantity } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
notes?: string;
availableQty?: number;
}
export default function TransferMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [sourceWarehouseId, setSourceWarehouseId] = useState<number | "">("");
const [destWarehouseId, setDestWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const { data: stockLevels } = useStockLevels({
warehouseId: sourceWarehouseId || undefined,
});
const createMutation = useCreateTransferMovement();
const confirmMutation = useConfirmMovement();
const getAvailableQty = (articleId: number): number => {
if (!stockLevels) return 0;
const level = stockLevels.find((l) => l.articleId === articleId);
return level ? level.availableQuantity : 0;
};
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
if (field === "article" && value) {
const article = value as ArticleDto;
updated.availableQty = getAvailableQty(article.id);
}
return updated;
}
return l;
}),
);
};
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!sourceWarehouseId) {
newErrors.sourceWarehouseId = "Seleziona magazzino origine";
}
if (!destWarehouseId) {
newErrors.destWarehouseId = "Seleziona magazzino destinazione";
}
if (
sourceWarehouseId &&
destWarehouseId &&
sourceWarehouseId === destWarehouseId
) {
newErrors.destWarehouseId =
"Origine e destinazione devono essere diversi";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateTransferDto = {
sourceWarehouseId: sourceWarehouseId as number,
destinationWarehouseId: destWarehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TransferIcon color="primary" />
<Box>
<Typography variant="h5" fontWeight="bold">
Trasferimento tra Magazzini
</Typography>
<Typography variant="body2" color="text.secondary">
Sposta merce da un magazzino all'altro
</Typography>
</Box>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{/* Form */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Trasferimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl
fullWidth
required
error={!!errors.sourceWarehouseId}
>
<InputLabel>Magazzino Origine</InputLabel>
<Select
value={sourceWarehouseId}
label="Magazzino Origine"
onChange={(e) =>
setSourceWarehouseId(e.target.value as number)
}
>
{warehouses
?.filter((w) => w.id !== destWarehouseId)
.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.destWarehouseId}>
<InputLabel>Magazzino Destinazione</InputLabel>
<Select
value={destWarehouseId}
label="Magazzino Destinazione"
onChange={(e) => setDestWarehouseId(e.target.value as number)}
>
{warehouses
?.filter((w) => w.id !== sourceWarehouseId)
.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
{errors.destWarehouseId && (
<Typography variant="caption" color="error">
{errors.destWarehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6">Articoli da Trasferire</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Articolo</TableCell>
<TableCell align="right">Disponibile</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell>Note</TableCell>
<TableCell width={60}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id}>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, v) =>
handleLineChange(line.id, "article", v)
}
options={articles || []}
getOptionLabel={(o) => `${o.code} - ${o.description}`}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Articolo"
/>
)}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
</TableCell>
<TableCell align="right">
{line.article && (
<Chip
label={`${formatQuantity(line.availableQty || 0)} ${line.article.unitOfMeasure}`}
size="small"
color={
(line.availableQty || 0) <= 0 ? "error" : "default"
}
/>
)}
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
value={line.notes || ""}
onChange={(e) =>
handleLineChange(line.id, "notes", e.target.value)
}
placeholder="Note"
fullWidth
/>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Typography variant="h6">
Totale: {formatQuantity(totalQuantity)}
</Typography>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
color="success"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending}
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,539 @@
import React from "react";
import {
Box,
Paper,
Typography,
Grid,
Card,
CardContent,
Button,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Divider,
Skeleton,
Alert,
} from "@mui/material";
import {
Inventory as InventoryIcon,
Warehouse as WarehouseIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
Warning as WarningIcon,
Schedule as ScheduleIcon,
ArrowForward as ArrowForwardIcon,
Add as AddIcon,
Assessment as AssessmentIcon,
} from "@mui/icons-material";
import {
useArticles,
useWarehouses,
useMovements,
useStockLevels,
useExpiringBatches,
} from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import {
MovementStatus,
MovementType,
formatCurrency,
formatQuantity,
formatDate,
movementTypeLabels,
getMovementTypeColor,
} from "../types";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
color?: string;
loading?: boolean;
}
function StatCard({
title,
value,
subtitle,
icon,
color = "primary.main",
loading,
}: StatCardProps) {
return (
<Card>
<CardContent>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{title}
</Typography>
{loading ? (
<Skeleton width={100} height={40} />
) : (
<Typography variant="h4" fontWeight="bold" sx={{ color }}>
{value}
</Typography>
)}
{subtitle && (
<Typography variant="caption" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box
sx={{
p: 1,
borderRadius: 2,
bgcolor: `${color}15`,
color,
}}
>
{icon}
</Box>
</Box>
</CardContent>
</Card>
);
}
export default function WarehouseDashboard() {
const nav = useWarehouseNavigation();
const { data: articles, isLoading: loadingArticles } = useArticles({
isActive: true,
});
const { data: warehouses, isLoading: loadingWarehouses } = useWarehouses({
active: true,
});
const { data: stockLevels, isLoading: loadingStock } = useStockLevels();
const { data: recentMovements, isLoading: loadingMovements } = useMovements();
const { data: expiringBatches } = useExpiringBatches(30);
// Calculate statistics
const totalArticles = articles?.length || 0;
const totalWarehouses = warehouses?.length || 0;
const stockStats = React.useMemo(() => {
if (!stockLevels) return { totalValue: 0, lowStock: 0, outOfStock: 0 };
let totalValue = 0;
let lowStock = 0;
let outOfStock = 0;
for (const level of stockLevels) {
totalValue += level.stockValue || 0;
if (level.quantity <= 0) outOfStock++;
else if (level.isLowStock) lowStock++;
}
return { totalValue, lowStock, outOfStock };
}, [stockLevels]);
// Recent movements (last 10)
const lastMovements = React.useMemo(() => {
if (!recentMovements) return [];
return recentMovements
.filter((m) => m.status === MovementStatus.Confirmed)
.slice(0, 10);
}, [recentMovements]);
// Pending movements (drafts)
const pendingMovements = React.useMemo(() => {
if (!recentMovements) return [];
return recentMovements.filter((m) => m.status === MovementStatus.Draft);
}, [recentMovements]);
// Low stock articles
const lowStockArticles = React.useMemo(() => {
if (!stockLevels || !articles) return [];
const lowIds = new Set(
stockLevels
.filter((l) => l.isLowStock || l.quantity <= 0)
.map((l) => l.articleId),
);
return articles.filter((a) => lowIds.has(a.id)).slice(0, 5);
}, [stockLevels, articles]);
// Get stock quantity for an article
const getArticleStock = (articleId: number) => {
if (!stockLevels) return 0;
return stockLevels
.filter((l) => l.articleId === articleId)
.reduce((sum, l) => sum + l.quantity, 0);
};
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 4,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box>
<Typography variant="h4" fontWeight="bold">
Magazzino
</Typography>
<Typography variant="body1" color="text.secondary">
Dashboard e panoramica giacenze
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={nav.goToNewInbound}
>
Nuovo Carico
</Button>
<Button
variant="contained"
startIcon={<AssessmentIcon />}
onClick={nav.goToStockLevels}
>
Giacenze
</Button>
</Box>
</Box>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Articoli Attivi"
value={totalArticles}
icon={<InventoryIcon />}
loading={loadingArticles}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Magazzini"
value={totalWarehouses}
icon={<WarehouseIcon />}
loading={loadingWarehouses}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Valore Totale"
value={formatCurrency(stockStats.totalValue)}
icon={<TrendingUpIcon />}
color="success.main"
loading={loadingStock}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Sotto Scorta"
value={stockStats.lowStock + stockStats.outOfStock}
subtitle={`${stockStats.outOfStock} esauriti`}
icon={<WarningIcon />}
color="warning.main"
loading={loadingStock}
/>
</Grid>
</Grid>
{/* Main Content */}
<Grid container spacing={3}>
{/* Recent Movements */}
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: "100%" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Ultimi Movimenti</Typography>
<Button
size="small"
endIcon={<ArrowForwardIcon />}
onClick={nav.goToMovements}
>
Vedi tutti
</Button>
</Box>
{loadingMovements ? (
<Box>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={60} sx={{ mb: 1 }} />
))}
</Box>
) : lastMovements.length === 0 ? (
<Typography color="text.secondary" textAlign="center" py={4}>
Nessun movimento recente
</Typography>
) : (
<List disablePadding>
{lastMovements.map((movement, index) => (
<React.Fragment key={movement.id}>
<ListItem
sx={{ px: 0, cursor: "pointer" }}
onClick={() => nav.goToMovement(movement.id)}
>
<ListItemIcon>
{movement.type === MovementType.Inbound ? (
<TrendingUpIcon color="success" />
) : movement.type === MovementType.Outbound ? (
<TrendingDownIcon color="error" />
) : (
<WarehouseIcon color="primary" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2" fontWeight="medium">
{movement.documentNumber || `MOV-${movement.id}`}
</Typography>
<Chip
label={movementTypeLabels[movement.type]}
size="small"
color={
getMovementTypeColor(movement.type) as
| "success"
| "error"
| "info"
| "warning"
| "default"
}
/>
</Box>
}
secondary={`${movement.sourceWarehouseName || movement.destinationWarehouseName || "-"} - ${formatDate(movement.movementDate)}`}
/>
<Typography variant="body2" color="text.secondary">
{formatQuantity(movement.lineCount)} righe
</Typography>
</ListItem>
{index < lastMovements.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
</Paper>
</Grid>
{/* Alerts Section */}
<Grid size={{ xs: 12, md: 6 }}>
<Grid container spacing={3}>
{/* Pending Movements */}
{pendingMovements.length > 0 && (
<Grid size={12}>
<Alert
severity="info"
icon={<ScheduleIcon />}
action={
<Button size="small" onClick={nav.goToMovements}>
Gestisci
</Button>
}
>
<strong>{pendingMovements.length}</strong> movimenti in bozza
da confermare
</Alert>
</Grid>
)}
{/* Expiring Batches */}
{expiringBatches && expiringBatches.length > 0 && (
<Grid size={12}>
<Alert
severity="warning"
icon={<ScheduleIcon />}
action={
<Button size="small" onClick={nav.goToBatches}>
Visualizza
</Button>
}
>
<strong>{expiringBatches.length}</strong> lotti in scadenza
nei prossimi 30 giorni
</Alert>
</Grid>
)}
{/* Low Stock Articles */}
<Grid size={12}>
<Paper sx={{ p: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Articoli Sotto Scorta</Typography>
<Button
size="small"
endIcon={<ArrowForwardIcon />}
onClick={nav.goToArticles}
>
Vedi tutti
</Button>
</Box>
{lowStockArticles.length === 0 ? (
<Typography color="text.secondary" textAlign="center" py={2}>
Nessun articolo sotto scorta
</Typography>
) : (
<List disablePadding>
{lowStockArticles.map((article, index) => {
const currentStock = getArticleStock(article.id);
return (
<React.Fragment key={article.id}>
<ListItem
sx={{ px: 0, cursor: "pointer" }}
onClick={() => nav.goToArticle(article.id)}
>
<ListItemIcon>
<WarningIcon color="warning" />
</ListItemIcon>
<ListItemText
primary={article.description}
secondary={article.code}
/>
<Chip
label={`${formatQuantity(currentStock)} ${article.unitOfMeasure}`}
size="small"
color={currentStock <= 0 ? "error" : "warning"}
/>
</ListItem>
{index < lowStockArticles.length - 1 && <Divider />}
</React.Fragment>
);
})}
</List>
)}
</Paper>
</Grid>
</Grid>
</Grid>
{/* Quick Actions */}
<Grid size={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Azioni Rapide
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewInbound}
>
<CardContent>
<TrendingUpIcon color="success" sx={{ fontSize: 40 }} />
<Typography variant="body2">Carico</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewOutbound}
>
<CardContent>
<TrendingDownIcon color="error" sx={{ fontSize: 40 }} />
<Typography variant="body2">Scarico</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewTransfer}
>
<CardContent>
<WarehouseIcon color="primary" sx={{ fontSize: 40 }} />
<Typography variant="body2">Trasferimento</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewArticle}
>
<CardContent>
<InventoryIcon color="info" sx={{ fontSize: 40 }} />
<Typography variant="body2">Nuovo Articolo</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewInventory}
>
<CardContent>
<AssessmentIcon color="secondary" sx={{ fontSize: 40 }} />
<Typography variant="body2">Inventario</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToValuation}
>
<CardContent>
<TrendingUpIcon color="warning" sx={{ fontSize: 40 }} />
<Typography variant="body2">Valorizzazione</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,496 @@
import { useState } from "react";
import {
Box,
Paper,
Typography,
Button,
IconButton,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Grid,
Alert,
Card,
CardContent,
CardActions,
Tooltip,
} from "@mui/material";
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
Warehouse as WarehouseIcon,
} from "@mui/icons-material";
import {
useWarehouses,
useCreateWarehouse,
useUpdateWarehouse,
useDeleteWarehouse,
useSetDefaultWarehouse,
} from "../hooks";
import {
WarehouseLocationDto,
WarehouseType,
warehouseTypeLabels,
} from "../types";
const initialFormData = {
code: "",
name: "",
description: "",
type: WarehouseType.Physical,
address: "",
isDefault: false,
isActive: true,
sortOrder: 0,
};
export default function WarehouseLocationsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editingWarehouse, setEditingWarehouse] =
useState<WarehouseLocationDto | null>(null);
const [warehouseToDelete, setWarehouseToDelete] =
useState<WarehouseLocationDto | null>(null);
const [formData, setFormData] = useState(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses, isLoading, error } = useWarehouses();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const deleteMutation = useDeleteWarehouse();
const setDefaultMutation = useSetDefaultWarehouse();
const handleOpenDialog = (warehouse?: WarehouseLocationDto) => {
if (warehouse) {
setEditingWarehouse(warehouse);
setFormData({
code: warehouse.code,
name: warehouse.name,
description: warehouse.description || "",
type: warehouse.type,
address: warehouse.address || "",
isDefault: warehouse.isDefault,
isActive: warehouse.isActive,
sortOrder: warehouse.sortOrder,
});
} else {
setEditingWarehouse(null);
setFormData(initialFormData);
}
setErrors({});
setDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingWarehouse(null);
setFormData(initialFormData);
setErrors({});
};
const handleChange = (field: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) {
newErrors.code = "Il codice è obbligatorio";
}
if (!formData.name.trim()) {
newErrors.name = "Il nome è obbligatorio";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
try {
if (editingWarehouse) {
await updateMutation.mutateAsync({
id: editingWarehouse.id,
data: formData,
});
} else {
await createMutation.mutateAsync(formData);
}
handleCloseDialog();
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const handleDeleteClick = (warehouse: WarehouseLocationDto) => {
setWarehouseToDelete(warehouse);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (warehouseToDelete) {
await deleteMutation.mutateAsync(warehouseToDelete.id);
setDeleteDialogOpen(false);
setWarehouseToDelete(null);
}
};
const handleSetDefault = async (warehouse: WarehouseLocationDto) => {
if (!warehouse.isDefault) {
await setDefaultMutation.mutateAsync(warehouse.id);
}
};
const getTypeColor = (
type: WarehouseType,
): "primary" | "warning" | "error" | "secondary" | "info" | "default" => {
switch (type) {
case WarehouseType.Physical:
return "primary";
case WarehouseType.Transit:
return "warning";
case WarehouseType.Returns:
return "error";
case WarehouseType.Defective:
return "secondary";
case WarehouseType.Subcontract:
return "info";
default:
return "default";
}
};
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento dei magazzini: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Gestione Magazzini
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Nuovo Magazzino
</Button>
</Box>
{/* Warehouse Cards */}
<Grid container spacing={3}>
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card sx={{ height: "100%" }}>
<CardContent>
<Box
sx={{ height: 120, bgcolor: "grey.100", borderRadius: 1 }}
/>
</CardContent>
</Card>
</Grid>
))
) : warehouses?.length === 0 ? (
<Grid size={12}>
<Paper sx={{ p: 4, textAlign: "center" }}>
<WarehouseIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
<Typography color="text.secondary">
Nessun magazzino configurato
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
sx={{ mt: 2 }}
>
Aggiungi il primo magazzino
</Button>
</Paper>
</Grid>
) : (
warehouses?.map((warehouse) => (
<Grid key={warehouse.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
position: "relative",
opacity: warehouse.isActive ? 1 : 0.6,
}}
>
{warehouse.isDefault && (
<Tooltip title="Magazzino Predefinito">
<StarIcon
sx={{
position: "absolute",
top: 8,
right: 8,
color: "warning.main",
}}
/>
</Tooltip>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
}}
>
<WarehouseIcon color="primary" />
<Typography variant="h6" component="div">
{warehouse.code}
</Typography>
</Box>
<Typography variant="body1" fontWeight="medium" gutterBottom>
{warehouse.name}
</Typography>
{warehouse.description && (
<Typography
variant="body2"
color="text.secondary"
gutterBottom
>
{warehouse.description}
</Typography>
)}
<Box
sx={{ mt: 2, display: "flex", gap: 1, flexWrap: "wrap" }}
>
<Chip
label={warehouseTypeLabels[warehouse.type]}
size="small"
color={getTypeColor(warehouse.type)}
/>
{!warehouse.isActive && (
<Chip label="Inattivo" size="small" color="default" />
)}
</Box>
{warehouse.address && (
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mt: 1 }}
>
{warehouse.address}
</Typography>
)}
</CardContent>
<CardActions>
<Tooltip title="Imposta come predefinito">
<IconButton
size="small"
onClick={() => handleSetDefault(warehouse)}
disabled={
warehouse.isDefault || setDefaultMutation.isPending
}
>
{warehouse.isDefault ? (
<StarIcon color="warning" />
) : (
<StarBorderIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title="Modifica">
<IconButton
size="small"
onClick={() => handleOpenDialog(warehouse)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Elimina">
<IconButton
size="small"
onClick={() => handleDeleteClick(warehouse)}
disabled={warehouse.isDefault}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
{/* Create/Edit Dialog */}
<Dialog
open={dialogOpen}
onClose={handleCloseDialog}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{editingWarehouse ? "Modifica Magazzino" : "Nuovo Magazzino"}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Codice"
value={formData.code}
onChange={(e) => handleChange("code", e.target.value)}
error={!!errors.code}
helperText={errors.code}
required
disabled={!!editingWarehouse}
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
fullWidth
label="Nome"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
error={!!errors.name}
helperText={errors.name}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Descrizione"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
multiline
rows={2}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel>Tipo</InputLabel>
<Select
value={formData.type}
label="Tipo"
onChange={(e) => handleChange("type", e.target.value)}
>
{Object.entries(warehouseTypeLabels).map(([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Indirizzo"
value={formData.address}
onChange={(e) => handleChange("address", e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isDefault}
onChange={(e) =>
handleChange("isDefault", e.target.checked)
}
/>
}
label="Magazzino Predefinito"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) => handleChange("isActive", e.target.checked)}
/>
}
label="Attivo"
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending
? "Salvataggio..."
: "Salva"}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare il magazzino{" "}
<strong>
{warehouseToDelete?.code} - {warehouseToDelete?.name}
</strong>
?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,18 @@
// Dashboard
export { default as WarehouseDashboard } from './WarehouseDashboard';
// Articles
export { default as ArticlesPage } from './ArticlesPage';
export { default as ArticleFormPage } from './ArticleFormPage';
// Warehouse Locations
export { default as WarehouseLocationsPage } from './WarehouseLocationsPage';
// Movements
export { default as MovementsPage } from './MovementsPage';
export { default as InboundMovementPage } from './InboundMovementPage';
export { default as OutboundMovementPage } from './OutboundMovementPage';
export { default as TransferMovementPage } from './TransferMovementPage';
// Stock
export { default as StockLevelsPage } from './StockLevelsPage';

View File

@@ -0,0 +1,52 @@
import { Routes, Route } from "react-router-dom";
import { WarehouseProvider } from "./contexts/WarehouseContext";
import {
WarehouseDashboard,
ArticlesPage,
ArticleFormPage,
WarehouseLocationsPage,
MovementsPage,
InboundMovementPage,
OutboundMovementPage,
TransferMovementPage,
StockLevelsPage,
} from "./pages";
export default function WarehouseRoutes() {
return (
<WarehouseProvider>
<Routes>
{/* Dashboard */}
<Route index element={<WarehouseDashboard />} />
{/* Articles */}
<Route path="articles" element={<ArticlesPage />} />
<Route path="articles/new" element={<ArticleFormPage />} />
<Route path="articles/:id" element={<ArticleFormPage />} />
<Route path="articles/:id/edit" element={<ArticleFormPage />} />
{/* Warehouse Locations */}
<Route path="locations" element={<WarehouseLocationsPage />} />
{/* Movements */}
<Route path="movements" element={<MovementsPage />} />
<Route path="movements/:id" element={<MovementsPage />} />
<Route path="movements/inbound/new" element={<InboundMovementPage />} />
<Route
path="movements/outbound/new"
element={<OutboundMovementPage />}
/>
<Route
path="movements/transfer/new"
element={<TransferMovementPage />}
/>
{/* Stock */}
<Route path="stock" element={<StockLevelsPage />} />
{/* Fallback */}
<Route path="*" element={<WarehouseDashboard />} />
</Routes>
</WarehouseProvider>
);
}

View File

@@ -0,0 +1,571 @@
import api from "../../../services/api";
import {
ArticleDto,
ArticleFilterDto,
ArticleStockDto,
ArticleValuationDto,
BatchDto,
BatchStatus,
CategoryDto,
CategoryTreeDto,
CreateArticleDto,
CreateBatchDto,
CreateCategoryDto,
CreateInventoryCountDto,
CreateMovementDto,
CreateSerialDto,
CreateSerialsBulkDto,
CreateTransferDto,
CreateAdjustmentDto,
CreateWarehouseDto,
InventoryCountDetailDto,
InventoryCountDto,
InventoryCountLineDto,
InventoryStatus,
MovementDetailDto,
MovementDto,
MovementFilterDto,
MovementReasonDto,
MovementType,
PeriodValuationDto,
QualityStatus,
SerialDto,
SerialStatus,
StockLevelDto,
StockLevelFilterDto,
StockSummaryDto,
UpdateArticleDto,
UpdateBatchDto,
UpdateCategoryDto,
UpdateWarehouseDto,
ValuationMethod,
WarehouseLocationDto,
} from "../types";
const BASE_URL = "/warehouse";
// ===============================================
// WAREHOUSE LOCATIONS
// ===============================================
export const warehouseLocationService = {
getAll: async (includeInactive = false): Promise<WarehouseLocationDto[]> => {
const response = await api.get(`${BASE_URL}/locations`, {
params: { includeInactive },
});
return response.data;
},
getById: async (id: number): Promise<WarehouseLocationDto> => {
const response = await api.get(`${BASE_URL}/locations/${id}`);
return response.data;
},
getDefault: async (): Promise<WarehouseLocationDto> => {
const response = await api.get(`${BASE_URL}/locations/default`);
return response.data;
},
create: async (data: CreateWarehouseDto): Promise<WarehouseLocationDto> => {
const response = await api.post(`${BASE_URL}/locations`, data);
return response.data;
},
update: async (
id: number,
data: UpdateWarehouseDto,
): Promise<WarehouseLocationDto> => {
const response = await api.put(`${BASE_URL}/locations/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/locations/${id}`);
},
setDefault: async (id: number): Promise<void> => {
await api.put(`${BASE_URL}/locations/${id}/set-default`);
},
};
// ===============================================
// ARTICLE CATEGORIES
// ===============================================
export const categoryService = {
getAll: async (includeInactive = false): Promise<CategoryDto[]> => {
const response = await api.get(`${BASE_URL}/categories`, {
params: { includeInactive },
});
return response.data;
},
getTree: async (): Promise<CategoryTreeDto[]> => {
const response = await api.get(`${BASE_URL}/categories/tree`);
return response.data;
},
getById: async (id: number): Promise<CategoryDto> => {
const response = await api.get(`${BASE_URL}/categories/${id}`);
return response.data;
},
create: async (data: CreateCategoryDto): Promise<CategoryDto> => {
const response = await api.post(`${BASE_URL}/categories`, data);
return response.data;
},
update: async (id: number, data: UpdateCategoryDto): Promise<CategoryDto> => {
const response = await api.put(`${BASE_URL}/categories/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/categories/${id}`);
},
};
// ===============================================
// ARTICLES
// ===============================================
export const articleService = {
getAll: async (filter?: ArticleFilterDto): Promise<ArticleDto[]> => {
const response = await api.get(`${BASE_URL}/articles`, { params: filter });
return response.data;
},
getById: async (id: number): Promise<ArticleDto> => {
const response = await api.get(`${BASE_URL}/articles/${id}`);
return response.data;
},
getByCode: async (code: string): Promise<ArticleDto> => {
const response = await api.get(`${BASE_URL}/articles/by-code/${code}`);
return response.data;
},
getByBarcode: async (barcode: string): Promise<ArticleDto> => {
const response = await api.get(
`${BASE_URL}/articles/by-barcode/${barcode}`,
);
return response.data;
},
create: async (data: CreateArticleDto): Promise<ArticleDto> => {
const response = await api.post(`${BASE_URL}/articles`, data);
return response.data;
},
update: async (id: number, data: UpdateArticleDto): Promise<ArticleDto> => {
const response = await api.put(`${BASE_URL}/articles/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/articles/${id}`);
},
uploadImage: async (id: number, file: File): Promise<void> => {
const formData = new FormData();
formData.append("file", file);
await api.post(`${BASE_URL}/articles/${id}/image`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
getImageUrl: (id: number): string => {
return `${api.defaults.baseURL}${BASE_URL}/articles/${id}/image`;
},
getStock: async (id: number): Promise<ArticleStockDto> => {
const response = await api.get(`${BASE_URL}/articles/${id}/stock`);
return response.data;
},
};
// ===============================================
// BATCHES
// ===============================================
export const batchService = {
getAll: async (
articleId?: number,
status?: BatchStatus,
): Promise<BatchDto[]> => {
const response = await api.get(`${BASE_URL}/batches`, {
params: { articleId, status },
});
return response.data;
},
getById: async (id: number): Promise<BatchDto> => {
const response = await api.get(`${BASE_URL}/batches/${id}`);
return response.data;
},
getByNumber: async (
articleId: number,
batchNumber: string,
): Promise<BatchDto> => {
const response = await api.get(
`${BASE_URL}/batches/by-number/${articleId}/${batchNumber}`,
);
return response.data;
},
create: async (data: CreateBatchDto): Promise<BatchDto> => {
const response = await api.post(`${BASE_URL}/batches`, data);
return response.data;
},
update: async (id: number, data: UpdateBatchDto): Promise<BatchDto> => {
const response = await api.put(`${BASE_URL}/batches/${id}`, data);
return response.data;
},
updateStatus: async (id: number, status: BatchStatus): Promise<void> => {
await api.put(`${BASE_URL}/batches/${id}/status`, { status });
},
getExpiring: async (daysThreshold = 30): Promise<BatchDto[]> => {
const response = await api.get(`${BASE_URL}/batches/expiring`, {
params: { daysThreshold },
});
return response.data;
},
recordQualityCheck: async (
id: number,
qualityStatus: QualityStatus,
notes?: string,
): Promise<BatchDto> => {
const response = await api.post(`${BASE_URL}/batches/${id}/quality-check`, {
qualityStatus,
notes,
});
return response.data;
},
};
// ===============================================
// SERIALS
// ===============================================
export const serialService = {
getAll: async (
articleId?: number,
status?: SerialStatus,
): Promise<SerialDto[]> => {
const response = await api.get(`${BASE_URL}/serials`, {
params: { articleId, status },
});
return response.data;
},
getById: async (id: number): Promise<SerialDto> => {
const response = await api.get(`${BASE_URL}/serials/${id}`);
return response.data;
},
getByNumber: async (
articleId: number,
serialNumber: string,
): Promise<SerialDto> => {
const response = await api.get(
`${BASE_URL}/serials/by-number/${articleId}/${serialNumber}`,
);
return response.data;
},
create: async (data: CreateSerialDto): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials`, data);
return response.data;
},
createBulk: async (data: CreateSerialsBulkDto): Promise<SerialDto[]> => {
const response = await api.post(`${BASE_URL}/serials/bulk`, data);
return response.data;
},
updateStatus: async (id: number, status: SerialStatus): Promise<void> => {
await api.put(`${BASE_URL}/serials/${id}/status`, { status });
},
registerSale: async (
id: number,
customerId?: number,
salesReference?: string,
): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials/${id}/sell`, {
customerId,
salesReference,
});
return response.data;
},
registerReturn: async (
id: number,
warehouseId: number,
isDefective: boolean,
): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials/${id}/return`, {
warehouseId,
isDefective,
});
return response.data;
},
};
// ===============================================
// STOCK LEVELS
// ===============================================
export const stockService = {
getAll: async (filter?: StockLevelFilterDto): Promise<StockLevelDto[]> => {
const response = await api.get(`${BASE_URL}/stock`, { params: filter });
return response.data;
},
get: async (
articleId: number,
warehouseId: number,
batchId?: number,
): Promise<StockLevelDto> => {
const response = await api.get(
`${BASE_URL}/stock/${articleId}/${warehouseId}`,
{
params: { batchId },
},
);
return response.data;
},
getLowStock: async (): Promise<StockLevelDto[]> => {
const response = await api.get(`${BASE_URL}/stock/low-stock`);
return response.data;
},
getSummary: async (articleId: number): Promise<StockSummaryDto> => {
const response = await api.get(`${BASE_URL}/stock/summary/${articleId}`);
return response.data;
},
getValuation: async (
articleId: number,
method?: ValuationMethod,
): Promise<ArticleValuationDto> => {
const response = await api.get(`${BASE_URL}/stock/valuation/${articleId}`, {
params: { method },
});
return response.data;
},
getPeriodValuation: async (
period: number,
warehouseId?: number,
): Promise<PeriodValuationDto[]> => {
const response = await api.get(
`${BASE_URL}/stock/valuation/period/${period}`,
{
params: { warehouseId },
},
);
return response.data;
},
calculatePeriodValuation: async (
articleId: number,
period: number,
warehouseId?: number,
): Promise<PeriodValuationDto> => {
const response = await api.post(`${BASE_URL}/stock/valuation/calculate`, {
articleId,
period,
warehouseId,
});
return response.data;
},
closePeriod: async (period: number): Promise<void> => {
await api.post(`${BASE_URL}/stock/valuation/close-period/${period}`);
},
recalculateAverageCost: async (
articleId: number,
): Promise<{ articleId: number; weightedAverageCost: number }> => {
const response = await api.post(
`${BASE_URL}/stock/recalculate-average/${articleId}`,
);
return response.data;
},
};
// ===============================================
// MOVEMENTS
// ===============================================
export const movementService = {
getAll: async (filter?: MovementFilterDto): Promise<MovementDto[]> => {
const response = await api.get(`${BASE_URL}/movements`, { params: filter });
return response.data;
},
getById: async (id: number): Promise<MovementDetailDto> => {
const response = await api.get(`${BASE_URL}/movements/${id}`);
return response.data;
},
getByDocumentNumber: async (
documentNumber: string,
): Promise<MovementDetailDto> => {
const response = await api.get(
`${BASE_URL}/movements/by-document/${documentNumber}`,
);
return response.data;
},
createInbound: async (
data: CreateMovementDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/inbound`, data);
return response.data;
},
createOutbound: async (
data: CreateMovementDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/outbound`, data);
return response.data;
},
createTransfer: async (
data: CreateTransferDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/transfer`, data);
return response.data;
},
createAdjustment: async (
data: CreateAdjustmentDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/adjustment`, data);
return response.data;
},
confirm: async (id: number): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/${id}/confirm`);
return response.data;
},
cancel: async (id: number): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/${id}/cancel`);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/movements/${id}`);
},
generateDocumentNumber: async (type: MovementType): Promise<string> => {
const response = await api.get(
`${BASE_URL}/movements/generate-number/${type}`,
);
return response.data.documentNumber;
},
getReasons: async (
type?: MovementType,
includeInactive = false,
): Promise<MovementReasonDto[]> => {
const response = await api.get(`${BASE_URL}/movements/reasons`, {
params: { type, includeInactive },
});
return response.data;
},
};
// ===============================================
// INVENTORY
// ===============================================
export const inventoryService = {
getAll: async (status?: InventoryStatus): Promise<InventoryCountDto[]> => {
const response = await api.get(`${BASE_URL}/inventory`, {
params: { status },
});
return response.data;
},
getById: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.get(`${BASE_URL}/inventory/${id}`);
return response.data;
},
create: async (data: CreateInventoryCountDto): Promise<InventoryCountDto> => {
const response = await api.post(`${BASE_URL}/inventory`, data);
return response.data;
},
start: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/start`);
return response.data;
},
complete: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/complete`);
return response.data;
},
confirm: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/confirm`);
return response.data;
},
cancel: async (id: number): Promise<InventoryCountDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/cancel`);
return response.data;
},
updateLine: async (
lineId: number,
countedQuantity: number,
countedBy?: string,
): Promise<InventoryCountLineDto> => {
const response = await api.put(
`${BASE_URL}/inventory/lines/${lineId}/count`,
{
countedQuantity,
countedBy,
},
);
return response.data;
},
updateLinesBatch: async (
inventoryId: number,
lines: { lineId: number; countedQuantity: number }[],
countedBy?: string,
): Promise<InventoryCountLineDto[]> => {
const response = await api.put(
`${BASE_URL}/inventory/${inventoryId}/count-batch`,
{
countedBy,
lines,
},
);
return response.data;
},
};
// Export all services
export default {
locations: warehouseLocationService,
categories: categoryService,
articles: articleService,
batches: batchService,
serials: serialService,
stock: stockService,
movements: movementService,
inventory: inventoryService,
};

View File

@@ -0,0 +1,921 @@
// =====================================================
// WAREHOUSE MODULE - TYPESCRIPT TYPES
// =====================================================
// ===============================================
// ENUMS
// ===============================================
export enum WarehouseType {
Physical = 0,
Logical = 1,
Transit = 2,
Returns = 3,
Defective = 4,
Subcontract = 5,
}
export enum StockManagementType {
Standard = 0,
NotManaged = 1,
VariableWeight = 2,
Kit = 3,
}
export enum ValuationMethod {
WeightedAverage = 0,
FIFO = 1,
LIFO = 2,
StandardCost = 3,
SpecificCost = 4,
}
export enum BatchStatus {
Available = 0,
Quarantine = 1,
Blocked = 2,
Expired = 3,
Depleted = 4,
}
export enum QualityStatus {
NotChecked = 0,
Approved = 1,
Rejected = 2,
ConditionallyApproved = 3,
}
export enum SerialStatus {
Available = 0,
Reserved = 1,
Sold = 2,
InRepair = 3,
Defective = 4,
Returned = 5,
Disposed = 6,
}
export enum MovementType {
Inbound = 0,
Outbound = 1,
Transfer = 2,
Adjustment = 3,
Production = 4,
Consumption = 5,
SupplierReturn = 6,
CustomerReturn = 7,
}
export enum MovementStatus {
Draft = 0,
Confirmed = 1,
Cancelled = 2,
}
export enum ExternalDocumentType {
PurchaseOrder = 0,
InboundDeliveryNote = 1,
PurchaseInvoice = 2,
SalesOrder = 3,
OutboundDeliveryNote = 4,
SalesInvoice = 5,
ProductionOrder = 6,
InventoryDocument = 7,
}
export enum BarcodeType {
EAN13 = 0,
EAN8 = 1,
UPCA = 2,
UPCE = 3,
Code128 = 4,
Code39 = 5,
QRCode = 6,
DataMatrix = 7,
Internal = 8,
}
export enum InventoryType {
Full = 0,
Partial = 1,
Cyclic = 2,
Sample = 3,
}
export enum InventoryStatus {
Draft = 0,
InProgress = 1,
Completed = 2,
Confirmed = 3,
Cancelled = 4,
}
// ===============================================
// WAREHOUSE LOCATION
// ===============================================
export interface WarehouseLocationDto {
id: number;
code: string;
name: string;
description?: string;
address?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
type: WarehouseType;
isDefault: boolean;
isActive: boolean;
sortOrder: number;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateWarehouseDto {
code: string;
name: string;
description?: string;
address?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
type: WarehouseType;
isDefault: boolean;
sortOrder: number;
notes?: string;
}
export interface UpdateWarehouseDto extends CreateWarehouseDto {
isActive: boolean;
}
// ===============================================
// ARTICLE CATEGORY
// ===============================================
export interface CategoryDto {
id: number;
code: string;
name: string;
description?: string;
parentCategoryId?: number;
parentCategoryName?: string;
level: number;
fullPath?: string;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
isActive: boolean;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CategoryTreeDto {
id: number;
code: string;
name: string;
description?: string;
level: number;
fullPath?: string;
icon?: string;
color?: string;
isActive: boolean;
children: CategoryTreeDto[];
}
export interface CreateCategoryDto {
code: string;
name: string;
description?: string;
parentCategoryId?: number;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
notes?: string;
}
export interface UpdateCategoryDto {
code: string;
name: string;
description?: string;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
isActive: boolean;
notes?: string;
}
// ===============================================
// ARTICLE
// ===============================================
export interface ArticleDto {
id: number;
code: string;
description: string;
shortDescription?: string;
barcode?: string;
manufacturerCode?: string;
categoryId?: number;
categoryName?: string;
unitOfMeasure: string;
secondaryUnitOfMeasure?: string;
unitConversionFactor?: number;
stockManagement: StockManagementType;
isBatchManaged: boolean;
isSerialManaged: boolean;
hasExpiry: boolean;
expiryWarningDays?: number;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
reorderQuantity?: number;
leadTimeDays?: number;
valuationMethod?: ValuationMethod;
standardCost?: number;
lastPurchaseCost?: number;
weightedAverageCost?: number;
baseSellingPrice?: number;
weight?: number;
volume?: number;
isActive: boolean;
notes?: string;
hasImage: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface CreateArticleDto {
code: string;
description: string;
shortDescription?: string;
barcode?: string;
manufacturerCode?: string;
categoryId?: number;
unitOfMeasure: string;
secondaryUnitOfMeasure?: string;
unitConversionFactor?: number;
stockManagement: StockManagementType;
isBatchManaged: boolean;
isSerialManaged: boolean;
hasExpiry: boolean;
expiryWarningDays?: number;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
reorderQuantity?: number;
leadTimeDays?: number;
valuationMethod?: ValuationMethod;
standardCost?: number;
baseSellingPrice?: number;
weight?: number;
volume?: number;
notes?: string;
}
export interface UpdateArticleDto extends CreateArticleDto {
isActive: boolean;
}
export interface ArticleFilterDto {
search?: string;
categoryId?: number;
isActive?: boolean;
isBatchManaged?: boolean;
isSerialManaged?: boolean;
skip?: number;
take?: number;
orderBy?: string;
orderDescending?: boolean;
}
export interface ArticleStockDto {
articleId: number;
articleCode: string;
articleDescription: string;
totalStock: number;
availableStock: number;
unitOfMeasure: string;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
isLowStock: boolean;
stockByWarehouse: WarehouseStockDto[];
}
export interface WarehouseStockDto {
warehouseId: number;
warehouseCode: string;
warehouseName: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
unitCost?: number;
stockValue?: number;
batchId?: number;
batchNumber?: string;
}
// ===============================================
// BATCH
// ===============================================
export interface BatchDto {
id: number;
articleId: number;
articleCode?: string;
articleDescription?: string;
batchNumber: string;
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
supplierId?: number;
unitCost?: number;
initialQuantity: number;
currentQuantity: number;
reservedQuantity: number;
availableQuantity: number;
status: BatchStatus;
qualityStatus?: QualityStatus;
lastQualityCheckDate?: string;
certifications?: string;
notes?: string;
isExpired: boolean;
daysToExpiry?: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateBatchDto {
articleId: number;
batchNumber: string;
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
supplierId?: number;
unitCost?: number;
initialQuantity: number;
certifications?: string;
notes?: string;
}
export interface UpdateBatchDto {
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
unitCost?: number;
certifications?: string;
notes?: string;
}
// ===============================================
// SERIAL
// ===============================================
export interface SerialDto {
id: number;
articleId: number;
articleCode?: string;
articleDescription?: string;
batchId?: number;
batchNumber?: string;
serialNumber: string;
manufacturerSerial?: string;
productionDate?: string;
warrantyExpiryDate?: string;
currentWarehouseId?: number;
currentWarehouseCode?: string;
currentWarehouseName?: string;
status: SerialStatus;
unitCost?: number;
supplierId?: number;
customerId?: number;
soldDate?: string;
salesReference?: string;
attributes?: string;
notes?: string;
isWarrantyValid: boolean;
daysToWarrantyExpiry?: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateSerialDto {
articleId: number;
batchId?: number;
serialNumber: string;
manufacturerSerial?: string;
productionDate?: string;
warrantyExpiryDate?: string;
warehouseId?: number;
unitCost?: number;
supplierId?: number;
attributes?: string;
notes?: string;
}
export interface CreateSerialsBulkDto {
articleId: number;
batchId?: number;
serialNumbers: string[];
productionDate?: string;
warrantyExpiryDate?: string;
warehouseId?: number;
unitCost?: number;
supplierId?: number;
}
// ===============================================
// STOCK LEVEL
// ===============================================
export interface StockLevelDto {
id: number;
articleId: number;
articleCode: string;
articleDescription: string;
categoryName?: string;
warehouseId: number;
warehouseCode: string;
warehouseName: string;
batchId?: number;
batchNumber?: string;
batchExpiryDate?: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
onOrderQuantity: number;
unitCost?: number;
stockValue?: number;
locationCode?: string;
lastMovementDate?: string;
lastInventoryDate?: string;
minimumStock?: number;
isLowStock: boolean;
}
export interface StockLevelFilterDto {
articleId?: number;
warehouseId?: number;
batchId?: number;
categoryId?: number;
onlyWithStock?: boolean;
onlyLowStock?: boolean;
skip?: number;
take?: number;
}
export interface StockSummaryDto {
articleId: number;
articleCode: string;
articleDescription: string;
unitOfMeasure: string;
totalStock: number;
availableStock: number;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
isLowStock: boolean;
totalValue: number;
warehouseCount: number;
stockByWarehouse: StockLevelDto[];
}
// ===============================================
// MOVEMENT
// ===============================================
export interface MovementDto {
id: number;
documentNumber: string;
movementDate: string;
type: MovementType;
status: MovementStatus;
sourceWarehouseId?: number;
sourceWarehouseCode?: string;
sourceWarehouseName?: string;
destinationWarehouseId?: number;
destinationWarehouseCode?: string;
destinationWarehouseName?: string;
reasonId?: number;
reasonDescription?: string;
externalReference?: string;
totalValue?: number;
lineCount: number;
confirmedDate?: string;
notes?: string;
createdAt?: string;
}
export interface MovementDetailDto extends MovementDto {
externalDocumentType?: ExternalDocumentType;
supplierId?: number;
customerId?: number;
confirmedBy?: string;
updatedAt?: string;
lines: MovementLineDto[];
}
export interface MovementLineDto {
id: number;
lineNumber: number;
articleId: number;
articleCode: string;
articleDescription: string;
batchId?: number;
batchNumber?: string;
serialId?: number;
serialNumber?: string;
quantity: number;
unitOfMeasure: string;
unitCost?: number;
lineValue?: number;
sourceLocationCode?: string;
destinationLocationCode?: string;
notes?: string;
}
export interface CreateMovementDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
warehouseId: number;
externalReference?: string;
externalDocumentType?: ExternalDocumentType;
supplierId?: number;
customerId?: number;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateTransferDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
sourceWarehouseId: number;
destinationWarehouseId: number;
externalReference?: string;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateAdjustmentDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
warehouseId: number;
externalReference?: string;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateMovementLineDto {
articleId: number;
batchId?: number;
serialId?: number;
quantity: number;
unitOfMeasure?: string;
unitCost?: number;
sourceLocationCode?: string;
destinationLocationCode?: string;
externalLineReference?: string;
notes?: string;
}
export interface MovementFilterDto {
dateFrom?: string;
dateTo?: string;
type?: MovementType;
status?: MovementStatus;
warehouseId?: number;
articleId?: number;
reasonId?: number;
externalReference?: string;
skip?: number;
take?: number;
orderBy?: string;
orderDescending?: boolean;
}
export interface MovementReasonDto {
id: number;
code: string;
description: string;
movementType: MovementType;
stockSign: number;
requiresExternalReference: boolean;
requiresValuation: boolean;
updatesAverageCost: boolean;
isSystem: boolean;
isActive: boolean;
}
// ===============================================
// VALUATION
// ===============================================
export interface ArticleValuationDto {
articleId: number;
articleCode: string;
articleDescription: string;
method: ValuationMethod;
totalQuantity: number;
unitOfMeasure: string;
weightedAverageCost: number;
standardCost?: number;
lastPurchaseCost?: number;
totalValue: number;
}
export interface PeriodValuationDto {
id: number;
period: number;
valuationDate: string;
articleId: number;
articleCode: string;
articleDescription: string;
warehouseId?: number;
warehouseCode?: string;
warehouseName?: string;
quantity: number;
method: ValuationMethod;
unitCost: number;
totalValue: number;
inboundQuantity: number;
inboundValue: number;
outboundQuantity: number;
outboundValue: number;
isClosed: boolean;
}
// ===============================================
// INVENTORY
// ===============================================
export interface InventoryCountDto {
id: number;
code: string;
description: string;
inventoryDate: string;
warehouseId?: number;
warehouseCode?: string;
warehouseName?: string;
categoryId?: number;
categoryName?: string;
type: InventoryType;
status: InventoryStatus;
startDate?: string;
endDate?: string;
confirmedDate?: string;
adjustmentMovementId?: number;
positiveDifferenceValue?: number;
negativeDifferenceValue?: number;
lineCount: number;
countedLineCount: number;
notes?: string;
createdAt?: string;
}
export interface InventoryCountDetailDto extends InventoryCountDto {
confirmedBy?: string;
updatedAt?: string;
lines: InventoryCountLineDto[];
}
export interface InventoryCountLineDto {
id: number;
articleId: number;
articleCode: string;
articleDescription: string;
warehouseId: number;
warehouseCode: string;
batchId?: number;
batchNumber?: string;
locationCode?: string;
theoreticalQuantity: number;
countedQuantity?: number;
difference?: number;
unitCost?: number;
differenceValue?: number;
countedAt?: string;
countedBy?: string;
secondCountQuantity?: number;
secondCountBy?: string;
notes?: string;
}
export interface CreateInventoryCountDto {
code?: string;
description: string;
inventoryDate?: string;
warehouseId?: number;
categoryId?: number;
type: InventoryType;
notes?: string;
}
// ===============================================
// HELPER FUNCTIONS
// ===============================================
export const warehouseTypeLabels: Record<WarehouseType, string> = {
[WarehouseType.Physical]: "Fisico",
[WarehouseType.Logical]: "Logico",
[WarehouseType.Transit]: "Transito",
[WarehouseType.Returns]: "Resi",
[WarehouseType.Defective]: "Difettosi",
[WarehouseType.Subcontract]: "Conto Lavoro",
};
export const stockManagementTypeLabels: Record<StockManagementType, string> = {
[StockManagementType.Standard]: "Standard",
[StockManagementType.NotManaged]: "Non Gestito",
[StockManagementType.VariableWeight]: "Peso Variabile",
[StockManagementType.Kit]: "Kit",
};
export const valuationMethodLabels: Record<ValuationMethod, string> = {
[ValuationMethod.WeightedAverage]: "Costo Medio Ponderato",
[ValuationMethod.FIFO]: "FIFO",
[ValuationMethod.LIFO]: "LIFO",
[ValuationMethod.StandardCost]: "Costo Standard",
[ValuationMethod.SpecificCost]: "Costo Specifico",
};
export const batchStatusLabels: Record<BatchStatus, string> = {
[BatchStatus.Available]: "Disponibile",
[BatchStatus.Quarantine]: "Quarantena",
[BatchStatus.Blocked]: "Bloccato",
[BatchStatus.Expired]: "Scaduto",
[BatchStatus.Depleted]: "Esaurito",
};
export const qualityStatusLabels: Record<QualityStatus, string> = {
[QualityStatus.NotChecked]: "Non Controllato",
[QualityStatus.Approved]: "Approvato",
[QualityStatus.Rejected]: "Respinto",
[QualityStatus.ConditionallyApproved]: "Approvato con Riserva",
};
export const serialStatusLabels: Record<SerialStatus, string> = {
[SerialStatus.Available]: "Disponibile",
[SerialStatus.Reserved]: "Riservato",
[SerialStatus.Sold]: "Venduto",
[SerialStatus.InRepair]: "In Riparazione",
[SerialStatus.Defective]: "Difettoso",
[SerialStatus.Returned]: "Reso",
[SerialStatus.Disposed]: "Dismesso",
};
export const movementTypeLabels: Record<MovementType, string> = {
[MovementType.Inbound]: "Carico",
[MovementType.Outbound]: "Scarico",
[MovementType.Transfer]: "Trasferimento",
[MovementType.Adjustment]: "Rettifica",
[MovementType.Production]: "Produzione",
[MovementType.Consumption]: "Consumo",
[MovementType.SupplierReturn]: "Reso a Fornitore",
[MovementType.CustomerReturn]: "Reso da Cliente",
};
export const movementStatusLabels: Record<MovementStatus, string> = {
[MovementStatus.Draft]: "Bozza",
[MovementStatus.Confirmed]: "Confermato",
[MovementStatus.Cancelled]: "Annullato",
};
export const inventoryTypeLabels: Record<InventoryType, string> = {
[InventoryType.Full]: "Completo",
[InventoryType.Partial]: "Parziale",
[InventoryType.Cyclic]: "Ciclico",
[InventoryType.Sample]: "A Campione",
};
export const inventoryStatusLabels: Record<InventoryStatus, string> = {
[InventoryStatus.Draft]: "Bozza",
[InventoryStatus.InProgress]: "In Corso",
[InventoryStatus.Completed]: "Completato",
[InventoryStatus.Confirmed]: "Confermato",
[InventoryStatus.Cancelled]: "Annullato",
};
export function formatCurrency(value: number | undefined | null): string {
if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
}).format(value);
}
export function formatQuantity(
value: number | undefined | null,
decimals: number = 2
): string {
if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
}).format(value);
}
export function formatDate(dateString: string | undefined | null): string {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("it-IT");
}
export function formatDateTime(dateString: string | undefined | null): string {
if (!dateString) return "-";
return new Date(dateString).toLocaleString("it-IT");
}
export function getMovementTypeColor(type: MovementType): string {
switch (type) {
case MovementType.Inbound:
case MovementType.Production:
case MovementType.CustomerReturn:
return "success";
case MovementType.Outbound:
case MovementType.Consumption:
case MovementType.SupplierReturn:
return "error";
case MovementType.Transfer:
return "info";
case MovementType.Adjustment:
return "warning";
default:
return "default";
}
}
export function getMovementStatusColor(status: MovementStatus): string {
switch (status) {
case MovementStatus.Draft:
return "warning";
case MovementStatus.Confirmed:
return "success";
case MovementStatus.Cancelled:
return "error";
default:
return "default";
}
}
export function getBatchStatusColor(status: BatchStatus): string {
switch (status) {
case BatchStatus.Available:
return "success";
case BatchStatus.Quarantine:
return "warning";
case BatchStatus.Blocked:
case BatchStatus.Expired:
return "error";
case BatchStatus.Depleted:
return "default";
default:
return "default";
}
}
export function getSerialStatusColor(status: SerialStatus): string {
switch (status) {
case SerialStatus.Available:
return "success";
case SerialStatus.Reserved:
return "info";
case SerialStatus.Sold:
return "primary";
case SerialStatus.Returned:
return "warning";
case SerialStatus.InRepair:
case SerialStatus.Defective:
return "error";
case SerialStatus.Disposed:
return "default";
default:
return "default";
}
}
export function getInventoryStatusColor(status: InventoryStatus): string {
switch (status) {
case InventoryStatus.Draft:
return "default";
case InventoryStatus.InProgress:
return "info";
case InventoryStatus.Completed:
return "warning";
case InventoryStatus.Confirmed:
return "success";
case InventoryStatus.Cancelled:
return "error";
default:
return "default";
}
}