-
This commit is contained in:
722
frontend/src/modules/warehouse/contexts/WarehouseContext.tsx
Normal file
722
frontend/src/modules/warehouse/contexts/WarehouseContext.tsx
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
76
frontend/src/modules/warehouse/hooks/index.ts
Normal file
76
frontend/src/modules/warehouse/hooks/index.ts
Normal 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";
|
||||
280
frontend/src/modules/warehouse/hooks/useStockCalculations.ts
Normal file
280
frontend/src/modules/warehouse/hooks/useStockCalculations.ts
Normal 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]);
|
||||
}
|
||||
160
frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
Normal file
160
frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
900
frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
Normal file
900
frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal file
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
Normal file
454
frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
650
frontend/src/modules/warehouse/pages/MovementsPage.tsx
Normal file
650
frontend/src/modules/warehouse/pages/MovementsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
481
frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
Normal file
481
frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
Normal file
355
frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
447
frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
Normal file
447
frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
539
frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
Normal file
539
frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
496
frontend/src/modules/warehouse/pages/WarehouseLocationsPage.tsx
Normal file
496
frontend/src/modules/warehouse/pages/WarehouseLocationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/modules/warehouse/pages/index.ts
Normal file
18
frontend/src/modules/warehouse/pages/index.ts
Normal 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';
|
||||
52
frontend/src/modules/warehouse/routes.tsx
Normal file
52
frontend/src/modules/warehouse/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
571
frontend/src/modules/warehouse/services/warehouseService.ts
Normal file
571
frontend/src/modules/warehouse/services/warehouseService.ts
Normal 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,
|
||||
};
|
||||
921
frontend/src/modules/warehouse/types/index.ts
Normal file
921
frontend/src/modules/warehouse/types/index.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user