[TASK-MASTER] feat: FE-001 products + FE-002 warehouses features
FE-001: Products Feature (16 archivos) - types/index.ts - Interfaces TypeScript - api/products.api.ts, categories.api.ts - Clientes Axios - hooks/useProducts, useCategories, useProductPricing - components/ProductForm, ProductCard, CategoryTree, VariantSelector, PricingTable - pages/ProductsPage, ProductDetailPage, CategoriesPage FE-002: Warehouses Feature (15 archivos) - types/index.ts - Interfaces TypeScript - api/warehouses.api.ts - Cliente Axios - hooks/useWarehouses, useLocations - components/WarehouseCard, LocationGrid, WarehouseLayout, ZoneCard, badges - pages/WarehousesPage, WarehouseDetailPage, LocationsPage, ZonesPage Ambos siguen patrones de inventory y React Query + react-hook-form + zod Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
29c76fcbd6
commit
158ebcb57b
119
src/features/products/api/categories.api.ts
Normal file
119
src/features/products/api/categories.api.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { api } from '@services/api/axios-instance';
|
||||||
|
import type {
|
||||||
|
ProductCategory,
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CategorySearchParams,
|
||||||
|
CategoriesResponse,
|
||||||
|
CategoryTreeNode,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const CATEGORIES_URL = '/api/v1/products/categories';
|
||||||
|
|
||||||
|
export const categoriesApi = {
|
||||||
|
// Get all categories with filters
|
||||||
|
getAll: async (params?: CategorySearchParams): Promise<CategoriesResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.search) searchParams.append('search', params.search);
|
||||||
|
if (params?.parentId) searchParams.append('parentId', params.parentId);
|
||||||
|
if (params?.isActive !== undefined) searchParams.append('isActive', String(params.isActive));
|
||||||
|
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||||
|
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = queryString ? `${CATEGORIES_URL}?${queryString}` : CATEGORIES_URL;
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number; limit: number; offset: number }>(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || 0,
|
||||||
|
limit: response.data.limit || 50,
|
||||||
|
offset: response.data.offset || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get category by ID
|
||||||
|
getById: async (id: string): Promise<ProductCategory> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Categoria no encontrada');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
create: async (data: CreateCategoryDto): Promise<ProductCategory> => {
|
||||||
|
const response = await api.post<{ success: boolean; data: ProductCategory }>(CATEGORIES_URL, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al crear categoria');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
update: async (id: string, data: UpdateCategoryDto): Promise<ProductCategory> => {
|
||||||
|
const response = await api.patch<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al actualizar categoria');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete category
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${CATEGORIES_URL}/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get root categories (no parent)
|
||||||
|
getRoots: async (): Promise<ProductCategory[]> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>(
|
||||||
|
`${CATEGORIES_URL}?parentId=`
|
||||||
|
);
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get children of a category
|
||||||
|
getChildren: async (parentId: string): Promise<ProductCategory[]> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>(
|
||||||
|
`${CATEGORIES_URL}?parentId=${parentId}`
|
||||||
|
);
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Build category tree from flat list
|
||||||
|
buildTree: (categories: ProductCategory[]): CategoryTreeNode[] => {
|
||||||
|
const map = new Map<string, CategoryTreeNode>();
|
||||||
|
const roots: CategoryTreeNode[] = [];
|
||||||
|
|
||||||
|
// Create nodes
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
map.set(cat.id, { ...cat, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
const node = map.get(cat.id);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
if (cat.parentId && map.has(cat.parentId)) {
|
||||||
|
const parent = map.get(cat.parentId);
|
||||||
|
parent?.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by sortOrder
|
||||||
|
const sortNodes = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => {
|
||||||
|
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
sortNodes(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return sortNodes(roots);
|
||||||
|
},
|
||||||
|
};
|
||||||
112
src/features/products/api/products.api.ts
Normal file
112
src/features/products/api/products.api.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { api } from '@services/api/axios-instance';
|
||||||
|
import type {
|
||||||
|
Product,
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
ProductSearchParams,
|
||||||
|
ProductsResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const PRODUCTS_URL = '/api/v1/products';
|
||||||
|
|
||||||
|
export const productsApi = {
|
||||||
|
// Get all products with filters
|
||||||
|
getAll: async (params?: ProductSearchParams): Promise<ProductsResponse> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.search) searchParams.append('search', params.search);
|
||||||
|
if (params?.categoryId) searchParams.append('categoryId', params.categoryId);
|
||||||
|
if (params?.productType) searchParams.append('productType', params.productType);
|
||||||
|
if (params?.isActive !== undefined) searchParams.append('isActive', String(params.isActive));
|
||||||
|
if (params?.isSellable !== undefined) searchParams.append('isSellable', String(params.isSellable));
|
||||||
|
if (params?.isPurchasable !== undefined) searchParams.append('isPurchasable', String(params.isPurchasable));
|
||||||
|
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||||
|
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = queryString ? `${PRODUCTS_URL}?${queryString}` : PRODUCTS_URL;
|
||||||
|
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || 0,
|
||||||
|
limit: response.data.limit || 50,
|
||||||
|
offset: response.data.offset || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get product by ID
|
||||||
|
getById: async (id: string): Promise<Product> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Producto no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get product by SKU
|
||||||
|
getBySku: async (sku: string): Promise<Product> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/sku/${sku}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Producto no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get product by barcode
|
||||||
|
getByBarcode: async (barcode: string): Promise<Product> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/barcode/${barcode}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Producto no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create product
|
||||||
|
create: async (data: CreateProductDto): Promise<Product> => {
|
||||||
|
const response = await api.post<{ success: boolean; data: Product }>(PRODUCTS_URL, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al crear producto');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update product
|
||||||
|
update: async (id: string, data: UpdateProductDto): Promise<Product> => {
|
||||||
|
const response = await api.patch<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al actualizar producto');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete product
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PRODUCTS_URL}/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get sellable products
|
||||||
|
getSellable: async (limit = 50, offset = 0): Promise<ProductsResponse> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(
|
||||||
|
`${PRODUCTS_URL}/sellable?limit=${limit}&offset=${offset}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || 0,
|
||||||
|
limit: response.data.limit || limit,
|
||||||
|
offset: response.data.offset || offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get purchasable products
|
||||||
|
getPurchasable: async (limit = 50, offset = 0): Promise<ProductsResponse> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(
|
||||||
|
`${PRODUCTS_URL}/purchasable?limit=${limit}&offset=${offset}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || 0,
|
||||||
|
limit: response.data.limit || limit,
|
||||||
|
offset: response.data.offset || offset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
270
src/features/products/components/CategoryTree.tsx
Normal file
270
src/features/products/components/CategoryTree.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { ChevronRight, ChevronDown, Folder, FolderOpen, Plus, MoreVertical } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import { useCategoryTree } from '../hooks';
|
||||||
|
import type { CategoryTreeNode, ProductCategory } from '../types';
|
||||||
|
|
||||||
|
export interface CategoryTreeProps {
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect?: (category: ProductCategory) => void;
|
||||||
|
onAdd?: (parentId?: string) => void;
|
||||||
|
onEdit?: (category: ProductCategory) => void;
|
||||||
|
onDelete?: (category: ProductCategory) => void;
|
||||||
|
showActions?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNodeProps {
|
||||||
|
node: CategoryTreeNode;
|
||||||
|
level: number;
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect?: (category: ProductCategory) => void;
|
||||||
|
onAdd?: (parentId: string) => void;
|
||||||
|
onEdit?: (category: ProductCategory) => void;
|
||||||
|
onDelete?: (category: ProductCategory) => void;
|
||||||
|
showActions?: boolean;
|
||||||
|
expandedIds: Set<string>;
|
||||||
|
toggleExpand: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
level,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
showActions,
|
||||||
|
expandedIds,
|
||||||
|
toggleExpand,
|
||||||
|
}: TreeNodeProps) {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const isExpanded = expandedIds.has(node.id);
|
||||||
|
const isSelected = selectedId === node.id;
|
||||||
|
|
||||||
|
const menuItems: DropdownItem[] = [
|
||||||
|
...(onAdd ? [{ key: 'add', label: 'Agregar subcategoria', onClick: () => onAdd(node.id) }] : []),
|
||||||
|
...(onEdit ? [{ key: 'edit', label: 'Editar', onClick: () => onEdit(node) }] : []),
|
||||||
|
...(onDelete ? [{ key: 'delete', label: 'Eliminar', onClick: () => onDelete(node), danger: true }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (hasChildren) {
|
||||||
|
toggleExpand(node.id);
|
||||||
|
}
|
||||||
|
onSelect?.(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-1 rounded-md px-2 py-1.5 cursor-pointer transition-colors',
|
||||||
|
isSelected ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Expand/collapse */}
|
||||||
|
<button
|
||||||
|
className="flex-shrink-0 p-0.5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (hasChildren) toggleExpand(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{isExpanded ? (
|
||||||
|
<FolderOpen className="h-4 w-4 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Folder className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span className="flex-1 truncate text-sm">{node.name}</span>
|
||||||
|
|
||||||
|
{/* Code badge */}
|
||||||
|
<span className="text-xs text-gray-400">{node.code}</span>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{showActions && menuItems.length > 0 && (
|
||||||
|
<Dropdown
|
||||||
|
items={menuItems}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className="invisible rounded p-0.5 hover:bg-gray-200 group-hover:visible"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children */}
|
||||||
|
{hasChildren && isExpanded && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
level={level + 1}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
showActions={showActions}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
toggleExpand={toggleExpand}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryTree({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
showActions = true,
|
||||||
|
className,
|
||||||
|
}: CategoryTreeProps) {
|
||||||
|
const { tree, isLoading, error } = useCategoryTree();
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = useCallback((id: string) => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandAll = useCallback(() => {
|
||||||
|
const allIds = new Set<string>();
|
||||||
|
const collectIds = (nodes: CategoryTreeNode[]) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
allIds.add(node.id);
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
collectIds(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
collectIds(tree);
|
||||||
|
setExpandedIds(allIds);
|
||||||
|
}, [tree]);
|
||||||
|
|
||||||
|
const collapseAll = useCallback(() => {
|
||||||
|
setExpandedIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center py-8', className)}>
|
||||||
|
<div className="text-sm text-gray-500">Cargando categorias...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center py-8', className)}>
|
||||||
|
<div className="text-sm text-red-500">Error al cargar categorias</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-8', className)}>
|
||||||
|
<Folder className="h-12 w-12 text-gray-300" />
|
||||||
|
<p className="mt-2 text-sm text-gray-500">No hay categorias</p>
|
||||||
|
{onAdd && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => onAdd()}
|
||||||
|
leftIcon={<Plus className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Crear categoria
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border bg-white', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Categorias</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
className="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
|
||||||
|
onClick={expandAll}
|
||||||
|
>
|
||||||
|
Expandir todo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
|
||||||
|
onClick={collapseAll}
|
||||||
|
>
|
||||||
|
Colapsar todo
|
||||||
|
</button>
|
||||||
|
{onAdd && (
|
||||||
|
<button
|
||||||
|
className="rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-50"
|
||||||
|
onClick={() => onAdd()}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
<div className="max-h-96 overflow-y-auto p-2">
|
||||||
|
{tree.map((node) => (
|
||||||
|
<TreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
level={0}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onAdd={onAdd}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
showActions={showActions}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
toggleExpand={toggleExpand}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/features/products/components/PricingTable.tsx
Normal file
260
src/features/products/components/PricingTable.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Calendar, Tag } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Badge } from '@components/atoms/Badge';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { useProductPrices, useProductPriceMutations, usePricingHelpers } from '../hooks';
|
||||||
|
import type { ProductPrice, PriceType } from '../types';
|
||||||
|
|
||||||
|
const priceTypeConfig: Record<PriceType, { label: string; color: 'info' | 'success' | 'warning' | 'primary' }> = {
|
||||||
|
standard: { label: 'Estandar', color: 'info' },
|
||||||
|
wholesale: { label: 'Mayoreo', color: 'success' },
|
||||||
|
retail: { label: 'Menudeo', color: 'warning' },
|
||||||
|
promo: { label: 'Promocion', color: 'primary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PricingTableProps {
|
||||||
|
productId: string;
|
||||||
|
productPrice?: number;
|
||||||
|
productCost?: number;
|
||||||
|
currency?: string;
|
||||||
|
onAddPrice?: () => void;
|
||||||
|
onEditPrice?: (price: ProductPrice) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingTable({
|
||||||
|
productId,
|
||||||
|
productPrice = 0,
|
||||||
|
productCost = 0,
|
||||||
|
currency = 'MXN',
|
||||||
|
onAddPrice,
|
||||||
|
onEditPrice,
|
||||||
|
className,
|
||||||
|
}: PricingTableProps) {
|
||||||
|
const { data, isLoading, error, refetch } = useProductPrices(productId);
|
||||||
|
const { delete: deletePrice, isDeleting } = useProductPriceMutations();
|
||||||
|
const { formatPrice, calculateMargin } = usePricingHelpers();
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [priceToDelete, setPriceToDelete] = useState<ProductPrice | null>(null);
|
||||||
|
|
||||||
|
const prices = data?.data || [];
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!priceToDelete) return;
|
||||||
|
try {
|
||||||
|
await deletePrice(priceToDelete.id);
|
||||||
|
refetch();
|
||||||
|
} finally {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setPriceToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (price: ProductPrice) => {
|
||||||
|
setPriceToDelete(price);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return true;
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date >= new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<ProductPrice>[] = [
|
||||||
|
{
|
||||||
|
key: 'priceType',
|
||||||
|
header: 'Tipo',
|
||||||
|
render: (row) => {
|
||||||
|
const config = priceTypeConfig[row.priceType];
|
||||||
|
return <Badge variant={config.color}>{config.label}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priceListName',
|
||||||
|
header: 'Lista de Precios',
|
||||||
|
render: (row) => row.priceListName || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
header: 'Precio',
|
||||||
|
align: 'right',
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-medium">{formatPrice(row.price, row.currency)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minQuantity',
|
||||||
|
header: 'Cant. Min',
|
||||||
|
align: 'right',
|
||||||
|
render: (row) => row.minQuantity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'margin',
|
||||||
|
header: 'Margen',
|
||||||
|
align: 'right',
|
||||||
|
render: (row) => {
|
||||||
|
if (productCost <= 0) return '-';
|
||||||
|
const margin = calculateMargin(row.price, productCost);
|
||||||
|
return (
|
||||||
|
<span className={cn(margin >= 0 ? 'text-green-600' : 'text-red-600')}>
|
||||||
|
{margin.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'validFrom',
|
||||||
|
header: 'Vigencia',
|
||||||
|
render: (row) => (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3 text-gray-400" />
|
||||||
|
<span>{formatDate(row.validFrom)}</span>
|
||||||
|
</div>
|
||||||
|
{row.validTo && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 mt-0.5',
|
||||||
|
!isValidDate(row.validTo) && 'text-red-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>al {formatDate(row.validTo)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (row) => (
|
||||||
|
<Badge variant={row.isActive ? 'success' : 'default'}>
|
||||||
|
{row.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{onEditPrice && (
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEditPrice(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDeleteModal(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Tag className="h-5 w-5" />
|
||||||
|
Precios del Producto
|
||||||
|
</CardTitle>
|
||||||
|
{onAddPrice && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddPrice}
|
||||||
|
leftIcon={<Plus className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Agregar precio
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Base price summary */}
|
||||||
|
<div className="border-b bg-gray-50 px-6 py-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Precio base del producto:</span>
|
||||||
|
<span className="font-semibold">{formatPrice(productPrice, currency)}</span>
|
||||||
|
</div>
|
||||||
|
{productCost > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-sm mt-1">
|
||||||
|
<span className="text-gray-600">Costo:</span>
|
||||||
|
<span className="text-gray-500">{formatPrice(productCost, currency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prices table */}
|
||||||
|
{error ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-red-500">
|
||||||
|
Error al cargar precios
|
||||||
|
</div>
|
||||||
|
) : prices.length === 0 && !isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-sm text-gray-500">
|
||||||
|
<Tag className="h-8 w-8 text-gray-300 mb-2" />
|
||||||
|
<p>No hay precios configurados</p>
|
||||||
|
{onAddPrice && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddPrice}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Agregar primer precio
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={prices}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No hay precios configurados"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setPriceToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Eliminar precio"
|
||||||
|
message={`¿Estas seguro de eliminar este precio? Esta accion no se puede deshacer.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/features/products/components/ProductCard.tsx
Normal file
121
src/features/products/components/ProductCard.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { Package, Tag, DollarSign, MoreVertical } from 'lucide-react';
|
||||||
|
import { Badge } from '@components/atoms/Badge';
|
||||||
|
import { Card, CardContent } from '@components/molecules/Card';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import type { Product, ProductType } from '../types';
|
||||||
|
|
||||||
|
const productTypeConfig: Record<ProductType, { label: string; color: 'info' | 'success' | 'warning' | 'primary' }> = {
|
||||||
|
product: { label: 'Producto', color: 'info' },
|
||||||
|
service: { label: 'Servicio', color: 'success' },
|
||||||
|
consumable: { label: 'Consumible', color: 'warning' },
|
||||||
|
kit: { label: 'Kit', color: 'primary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProductCardProps {
|
||||||
|
product: Product;
|
||||||
|
onClick?: (product: Product) => void;
|
||||||
|
onEdit?: (product: Product) => void;
|
||||||
|
onDelete?: (product: Product) => void;
|
||||||
|
onDuplicate?: (product: Product) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard({
|
||||||
|
product,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
}: ProductCardProps) {
|
||||||
|
const typeConfig = productTypeConfig[product.productType];
|
||||||
|
const hasImage = !!product.imageUrl;
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: product.currency || 'MXN',
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems: DropdownItem[] = [
|
||||||
|
...(onEdit ? [{ key: 'edit', label: 'Editar', onClick: () => onEdit(product) }] : []),
|
||||||
|
...(onDuplicate ? [{ key: 'duplicate', label: 'Duplicar', onClick: () => onDuplicate(product) }] : []),
|
||||||
|
...(onDelete ? [{ key: 'delete', label: 'Eliminar', onClick: () => onDelete(product), danger: true }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`relative transition-shadow hover:shadow-md ${onClick ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={() => onClick?.(product)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Image */}
|
||||||
|
<div className="h-20 w-20 flex-shrink-0 overflow-hidden rounded-lg bg-gray-100">
|
||||||
|
{hasImage ? (
|
||||||
|
<img
|
||||||
|
src={product.imageUrl!}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Package className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 truncate">{product.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{product.sku}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menuItems.length > 0 && (
|
||||||
|
<Dropdown
|
||||||
|
items={menuItems}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
className="rounded p-1 hover:bg-gray-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={typeConfig.color} size="sm">
|
||||||
|
{typeConfig.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{product.category && (
|
||||||
|
<Badge variant="default" size="sm">
|
||||||
|
<Tag className="mr-1 h-3 w-3" />
|
||||||
|
{product.category.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!product.isActive && (
|
||||||
|
<Badge variant="default" size="sm">Inactivo</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<DollarSign className="h-4 w-4" />
|
||||||
|
<span className="font-semibold text-gray-900">{formatPrice(product.price)}</span>
|
||||||
|
{product.cost > 0 && (
|
||||||
|
<span className="text-gray-400">/ Costo: {formatPrice(product.cost)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/features/products/components/ProductForm.tsx
Normal file
303
src/features/products/components/ProductForm.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { FormField } from '@components/molecules/FormField';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { useCategoryOptions } from '../hooks';
|
||||||
|
import type { Product, CreateProductDto, UpdateProductDto, ProductType } from '../types';
|
||||||
|
|
||||||
|
const productSchema = z.object({
|
||||||
|
sku: z.string().min(1, 'SKU es requerido').max(50, 'Maximo 50 caracteres'),
|
||||||
|
name: z.string().min(1, 'Nombre es requerido').max(200, 'Maximo 200 caracteres'),
|
||||||
|
description: z.string().max(2000, 'Maximo 2000 caracteres').optional(),
|
||||||
|
shortDescription: z.string().max(500, 'Maximo 500 caracteres').optional(),
|
||||||
|
barcode: z.string().max(50, 'Maximo 50 caracteres').optional(),
|
||||||
|
categoryId: z.string().uuid('Categoria invalida').optional().or(z.literal('')),
|
||||||
|
productType: z.enum(['product', 'service', 'consumable', 'kit']),
|
||||||
|
price: z.coerce.number().min(0, 'Debe ser mayor o igual a 0'),
|
||||||
|
cost: z.coerce.number().min(0, 'Debe ser mayor o igual a 0'),
|
||||||
|
currency: z.string().length(3, 'Debe ser un codigo de 3 letras').default('MXN'),
|
||||||
|
taxRate: z.coerce.number().min(0).max(100, 'Debe ser entre 0 y 100'),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
isSellable: z.boolean(),
|
||||||
|
isPurchasable: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProductFormData = z.infer<typeof productSchema>;
|
||||||
|
|
||||||
|
const productTypeLabels: Record<ProductType, string> = {
|
||||||
|
product: 'Producto',
|
||||||
|
service: 'Servicio',
|
||||||
|
consumable: 'Consumible',
|
||||||
|
kit: 'Kit',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProductFormProps {
|
||||||
|
product?: Product | null;
|
||||||
|
onSubmit: (data: CreateProductDto | UpdateProductDto) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductForm({
|
||||||
|
product,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isLoading = false,
|
||||||
|
}: ProductFormProps) {
|
||||||
|
const { options: categoryOptions, isLoading: categoriesLoading } = useCategoryOptions();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
} = useForm<ProductFormData>({
|
||||||
|
resolver: zodResolver(productSchema),
|
||||||
|
defaultValues: {
|
||||||
|
sku: product?.sku ?? '',
|
||||||
|
name: product?.name ?? '',
|
||||||
|
description: product?.description ?? '',
|
||||||
|
shortDescription: product?.shortDescription ?? '',
|
||||||
|
barcode: product?.barcode ?? '',
|
||||||
|
categoryId: product?.categoryId ?? '',
|
||||||
|
productType: product?.productType ?? 'product',
|
||||||
|
price: product?.price ?? 0,
|
||||||
|
cost: product?.cost ?? 0,
|
||||||
|
currency: product?.currency ?? 'MXN',
|
||||||
|
taxRate: product?.taxRate ?? 16,
|
||||||
|
isActive: product?.isActive ?? true,
|
||||||
|
isSellable: product?.isSellable ?? true,
|
||||||
|
isPurchasable: product?.isPurchasable ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedPrice = watch('price');
|
||||||
|
const watchedCost = watch('cost');
|
||||||
|
const margin = watchedCost > 0 ? ((watchedPrice - watchedCost) / watchedCost * 100).toFixed(2) : '0.00';
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: ProductFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
description: data.description || undefined,
|
||||||
|
shortDescription: data.shortDescription || undefined,
|
||||||
|
barcode: data.barcode || undefined,
|
||||||
|
categoryId: data.categoryId || undefined,
|
||||||
|
};
|
||||||
|
await onSubmit(cleanData as CreateProductDto);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
|
{/* Identificacion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Identificacion del Producto</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="SKU" error={errors.sku?.message} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('sku')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Ej: PROD-001"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Codigo de Barras" error={errors.barcode?.message}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('barcode')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Ej: 7501234567890"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Nombre del Producto" error={errors.name?.message} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Ej: Laptop HP Pavilion 15"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Descripcion Corta" error={errors.shortDescription?.message}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('shortDescription')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Breve descripcion del producto"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Descripcion Completa" error={errors.description?.message}>
|
||||||
|
<textarea
|
||||||
|
{...register('description')}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Descripcion detallada del producto..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Clasificacion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Clasificacion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Tipo de Producto" error={errors.productType?.message} required>
|
||||||
|
<select
|
||||||
|
{...register('productType')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{Object.entries(productTypeLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Categoria" error={errors.categoryId?.message}>
|
||||||
|
<select
|
||||||
|
{...register('categoryId')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
disabled={categoriesLoading}
|
||||||
|
>
|
||||||
|
<option value="">Sin categoria</option>
|
||||||
|
{categoryOptions.map((opt: { value: string; label: string }) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Precios */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Precios e Impuestos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<FormField label="Costo" error={errors.cost?.message} hint="Costo de adquisicion">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('cost')}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-8 pr-3 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="0.00"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Precio de Venta" error={errors.price?.message} hint="Precio al publico">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('price')}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-8 pr-3 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="0.00"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Margen" hint="Calculado automaticamente">
|
||||||
|
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3">
|
||||||
|
<span className={`font-medium ${Number(margin) >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{margin}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Moneda" error={errors.currency?.message}>
|
||||||
|
<select
|
||||||
|
{...register('currency')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="MXN">MXN - Peso Mexicano</option>
|
||||||
|
<option value="USD">USD - Dolar Americano</option>
|
||||||
|
<option value="EUR">EUR - Euro</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Tasa de IVA (%)" error={errors.taxRate?.message}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
{...register('taxRate')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="16"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estado y Configuracion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Estado y Configuracion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:gap-8">
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('isActive')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Producto activo</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('isSellable')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Disponible para venta</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('isPurchasable')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Disponible para compra</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Acciones */}
|
||||||
|
<div className="flex justify-end gap-3 border-t pt-6">
|
||||||
|
{onCancel && (
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
{product ? 'Guardar cambios' : 'Crear producto'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/features/products/components/VariantSelector.tsx
Normal file
256
src/features/products/components/VariantSelector.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { ProductVariant, ProductAttribute } from '../types';
|
||||||
|
|
||||||
|
export interface VariantSelectorProps {
|
||||||
|
variants: ProductVariant[];
|
||||||
|
attributes?: ProductAttribute[];
|
||||||
|
selectedVariant?: ProductVariant | null;
|
||||||
|
onSelect?: (variant: ProductVariant | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariantSelector({
|
||||||
|
variants,
|
||||||
|
attributes = [],
|
||||||
|
selectedVariant,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
}: VariantSelectorProps) {
|
||||||
|
const [selections, setSelections] = useState<Map<string, string>>(() => {
|
||||||
|
const map = new Map();
|
||||||
|
// Initialize from selected variant if provided
|
||||||
|
if (selectedVariant) {
|
||||||
|
// Would parse variant.name or attributes to set initial selections
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectValue = useCallback(
|
||||||
|
(attributeId: string, valueId: string) => {
|
||||||
|
const newSelections = new Map(selections);
|
||||||
|
|
||||||
|
if (newSelections.get(attributeId) === valueId) {
|
||||||
|
// Deselect if clicking same value
|
||||||
|
newSelections.delete(attributeId);
|
||||||
|
} else {
|
||||||
|
newSelections.set(attributeId, valueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelections(newSelections);
|
||||||
|
|
||||||
|
// Find matching variant
|
||||||
|
if (newSelections.size === attributes.length && attributes.length > 0) {
|
||||||
|
// Logic to find variant matching all selections
|
||||||
|
// This would need to be implemented based on how variants store attribute values
|
||||||
|
const matchingVariant = variants.find((v) => v.isActive);
|
||||||
|
if (matchingVariant) {
|
||||||
|
onSelect?.(matchingVariant);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onSelect?.(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selections, attributes, variants, onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attributes.length === 0) {
|
||||||
|
// Simple variant list if no attributes defined
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<p className="text-sm font-medium text-gray-700">Variantes disponibles</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{variants
|
||||||
|
.filter((v) => v.isActive)
|
||||||
|
.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||||
|
selectedVariant?.id === variant.id
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect?.(variant)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{variant.name}</div>
|
||||||
|
{variant.priceExtra !== 0 && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{variant.priceExtra > 0 ? '+' : ''}
|
||||||
|
${variant.priceExtra.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{attributes.map((attr) => (
|
||||||
|
<AttributeSelector
|
||||||
|
key={attr.id}
|
||||||
|
attribute={attr}
|
||||||
|
selectedValueId={selections.get(attr.id)}
|
||||||
|
onSelectValue={(valueId) => handleSelectValue(attr.id, valueId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedVariant && (
|
||||||
|
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">
|
||||||
|
{selectedVariant.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-green-600">
|
||||||
|
SKU: {selectedVariant.sku}
|
||||||
|
{selectedVariant.priceExtra !== 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
{selectedVariant.priceExtra > 0 ? '+' : ''}${selectedVariant.priceExtra.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Attribute Selector Component ====================
|
||||||
|
|
||||||
|
interface AttributeSelectorProps {
|
||||||
|
attribute: ProductAttribute;
|
||||||
|
selectedValueId?: string;
|
||||||
|
onSelectValue: (valueId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttributeSelector({
|
||||||
|
attribute,
|
||||||
|
selectedValueId,
|
||||||
|
onSelectValue,
|
||||||
|
}: AttributeSelectorProps) {
|
||||||
|
const values = attribute.values || [];
|
||||||
|
|
||||||
|
switch (attribute.displayType) {
|
||||||
|
case 'color':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-gray-700">{attribute.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value.id}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-full border-2 transition-all',
|
||||||
|
selectedValueId === value.id
|
||||||
|
? 'border-blue-500 ring-2 ring-blue-200'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: value.htmlColor || '#gray' }}
|
||||||
|
title={value.name}
|
||||||
|
onClick={() => onSelectValue(value.id)}
|
||||||
|
>
|
||||||
|
{selectedValueId === value.id && (
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 mx-auto',
|
||||||
|
isLightColor(value.htmlColor) ? 'text-gray-800' : 'text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'pills':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-gray-700">{attribute.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
selectedValueId === value.id
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectValue(value.id)}
|
||||||
|
>
|
||||||
|
{value.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-gray-700">{attribute.name}</p>
|
||||||
|
<select
|
||||||
|
value={selectedValueId || ''}
|
||||||
|
onChange={(e) => onSelectValue(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Seleccionar {attribute.name.toLowerCase()}</option>
|
||||||
|
{values.map((value) => (
|
||||||
|
<option key={value.id} value={value.id}>
|
||||||
|
{value.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-gray-700">{attribute.name}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{values.map((value) => (
|
||||||
|
<label
|
||||||
|
key={value.id}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors',
|
||||||
|
selectedValueId === value.id
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`attr-${attribute.id}`}
|
||||||
|
value={value.id}
|
||||||
|
checked={selectedValueId === value.id}
|
||||||
|
onChange={() => onSelectValue(value.id)}
|
||||||
|
className="h-4 w-4 text-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{value.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to determine if a color is light
|
||||||
|
function isLightColor(hex: string | null | undefined): boolean {
|
||||||
|
if (!hex) return true;
|
||||||
|
const color = hex.replace('#', '');
|
||||||
|
const r = parseInt(color.substring(0, 2), 16);
|
||||||
|
const g = parseInt(color.substring(2, 4), 16);
|
||||||
|
const b = parseInt(color.substring(4, 6), 16);
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
return brightness > 155;
|
||||||
|
}
|
||||||
14
src/features/products/components/index.ts
Normal file
14
src/features/products/components/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export { ProductForm } from './ProductForm';
|
||||||
|
export type { ProductFormProps } from './ProductForm';
|
||||||
|
|
||||||
|
export { ProductCard } from './ProductCard';
|
||||||
|
export type { ProductCardProps } from './ProductCard';
|
||||||
|
|
||||||
|
export { CategoryTree } from './CategoryTree';
|
||||||
|
export type { CategoryTreeProps } from './CategoryTree';
|
||||||
|
|
||||||
|
export { VariantSelector } from './VariantSelector';
|
||||||
|
export type { VariantSelectorProps } from './VariantSelector';
|
||||||
|
|
||||||
|
export { PricingTable } from './PricingTable';
|
||||||
|
export type { PricingTableProps } from './PricingTable';
|
||||||
32
src/features/products/hooks/index.ts
Normal file
32
src/features/products/hooks/index.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// Products Hooks
|
||||||
|
export {
|
||||||
|
useProducts,
|
||||||
|
useProduct,
|
||||||
|
useProductBySku,
|
||||||
|
useProductByBarcode,
|
||||||
|
useSellableProducts,
|
||||||
|
usePurchasableProducts,
|
||||||
|
useProductMutations,
|
||||||
|
useProductSearch,
|
||||||
|
} from './useProducts';
|
||||||
|
export type { UseProductsOptions } from './useProducts';
|
||||||
|
|
||||||
|
// Categories Hooks
|
||||||
|
export {
|
||||||
|
useCategories,
|
||||||
|
useCategory,
|
||||||
|
useCategoryTree,
|
||||||
|
useRootCategories,
|
||||||
|
useChildCategories,
|
||||||
|
useCategoryMutations,
|
||||||
|
useCategoryOptions,
|
||||||
|
} from './useCategories';
|
||||||
|
export type { UseCategoriesOptions } from './useCategories';
|
||||||
|
|
||||||
|
// Pricing Hooks
|
||||||
|
export {
|
||||||
|
useProductPrices,
|
||||||
|
useProductPrice,
|
||||||
|
useProductPriceMutations,
|
||||||
|
usePricingHelpers,
|
||||||
|
} from './useProductPricing';
|
||||||
142
src/features/products/hooks/useCategories.ts
Normal file
142
src/features/products/hooks/useCategories.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { categoriesApi } from '../api/categories.api';
|
||||||
|
import type {
|
||||||
|
ProductCategory,
|
||||||
|
CreateCategoryDto,
|
||||||
|
UpdateCategoryDto,
|
||||||
|
CategorySearchParams,
|
||||||
|
CategoryTreeNode,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const QUERY_KEY = 'product-categories';
|
||||||
|
|
||||||
|
export interface UseCategoriesOptions extends CategorySearchParams {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Categories List Hook ====================
|
||||||
|
|
||||||
|
export function useCategories(options: UseCategoriesOptions = {}) {
|
||||||
|
const { enabled = true, ...params } = options;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'list', params],
|
||||||
|
queryFn: () => categoriesApi.getAll(params),
|
||||||
|
enabled,
|
||||||
|
staleTime: 1000 * 60 * 10, // 10 minutes - categories change less frequently
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Single Category Hook ====================
|
||||||
|
|
||||||
|
export function useCategory(id: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'detail', id],
|
||||||
|
queryFn: () => categoriesApi.getById(id as string),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Category Tree Hook ====================
|
||||||
|
|
||||||
|
export function useCategoryTree() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'all'],
|
||||||
|
queryFn: () => categoriesApi.getAll({ limit: 1000 }), // Get all for tree
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = useMemo<CategoryTreeNode[]>(() => {
|
||||||
|
if (!data?.data) return [];
|
||||||
|
return categoriesApi.buildTree(data.data);
|
||||||
|
}, [data?.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
categories: data?.data || [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Root Categories Hook ====================
|
||||||
|
|
||||||
|
export function useRootCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'roots'],
|
||||||
|
queryFn: () => categoriesApi.getRoots(),
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Child Categories Hook ====================
|
||||||
|
|
||||||
|
export function useChildCategories(parentId: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'children', parentId],
|
||||||
|
queryFn: () => categoriesApi.getChildren(parentId as string),
|
||||||
|
enabled: !!parentId,
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Category Mutations Hook ====================
|
||||||
|
|
||||||
|
export function useCategoryMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateCategoryDto) => categoriesApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateCategoryDto }) =>
|
||||||
|
categoriesApi.update(id, data),
|
||||||
|
onSuccess: (_: unknown, variables: { id: string; data: UpdateCategoryDto }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => categoriesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: createMutation.mutateAsync,
|
||||||
|
update: updateMutation.mutateAsync,
|
||||||
|
delete: deleteMutation.mutateAsync,
|
||||||
|
isCreating: createMutation.isPending,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
createError: createMutation.error,
|
||||||
|
updateError: updateMutation.error,
|
||||||
|
deleteError: deleteMutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Category Options Hook (for selects) ====================
|
||||||
|
|
||||||
|
export function useCategoryOptions() {
|
||||||
|
const { data, isLoading } = useCategories({ isActive: true, limit: 500 });
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (!data?.data) return [];
|
||||||
|
return data.data.map((cat: ProductCategory) => ({
|
||||||
|
value: cat.id,
|
||||||
|
label: cat.hierarchyPath ? `${cat.hierarchyPath} / ${cat.name}` : cat.name,
|
||||||
|
category: cat,
|
||||||
|
}));
|
||||||
|
}, [data?.data]);
|
||||||
|
|
||||||
|
return { options, isLoading };
|
||||||
|
}
|
||||||
153
src/features/products/hooks/useProductPricing.ts
Normal file
153
src/features/products/hooks/useProductPricing.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@services/api/axios-instance';
|
||||||
|
import type { ProductPrice, CreatePriceDto, UpdatePriceDto, PricesResponse } from '../types';
|
||||||
|
|
||||||
|
const PRICES_URL = '/api/v1/products/prices';
|
||||||
|
const QUERY_KEY = 'product-prices';
|
||||||
|
|
||||||
|
// ==================== API Functions ====================
|
||||||
|
|
||||||
|
const pricesApi = {
|
||||||
|
getByProduct: async (productId: string): Promise<PricesResponse> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductPrice[]; total: number }>(
|
||||||
|
`${PRICES_URL}?productId=${productId}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<ProductPrice> => {
|
||||||
|
const response = await api.get<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Precio no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreatePriceDto): Promise<ProductPrice> => {
|
||||||
|
const response = await api.post<{ success: boolean; data: ProductPrice }>(PRICES_URL, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al crear precio');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdatePriceDto): Promise<ProductPrice> => {
|
||||||
|
const response = await api.patch<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`, data);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Error al actualizar precio');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PRICES_URL}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Product Prices Hook ====================
|
||||||
|
|
||||||
|
export function useProductPrices(productId: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'byProduct', productId],
|
||||||
|
queryFn: () => pricesApi.getByProduct(productId as string),
|
||||||
|
enabled: !!productId,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Single Price Hook ====================
|
||||||
|
|
||||||
|
export function useProductPrice(id: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'detail', id],
|
||||||
|
queryFn: () => pricesApi.getById(id as string),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Price Mutations Hook ====================
|
||||||
|
|
||||||
|
export function useProductPriceMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreatePriceDto) => pricesApi.create(data),
|
||||||
|
onSuccess: (_: unknown, variables: CreatePriceDto) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'byProduct', variables.productId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdatePriceDto }) => pricesApi.update(id, data),
|
||||||
|
onSuccess: (_: unknown, variables: { id: string; data: UpdatePriceDto }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => pricesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: createMutation.mutateAsync,
|
||||||
|
update: updateMutation.mutateAsync,
|
||||||
|
delete: deleteMutation.mutateAsync,
|
||||||
|
isCreating: createMutation.isPending,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
createError: createMutation.error,
|
||||||
|
updateError: updateMutation.error,
|
||||||
|
deleteError: deleteMutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Pricing Helpers ====================
|
||||||
|
|
||||||
|
export function usePricingHelpers() {
|
||||||
|
// Format price with currency
|
||||||
|
const formatPrice = (price: number, currency = 'MXN'): string => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate price with tax
|
||||||
|
const calculatePriceWithTax = (price: number, taxRate: number): number => {
|
||||||
|
return price * (1 + taxRate / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate price without tax
|
||||||
|
const calculatePriceWithoutTax = (priceWithTax: number, taxRate: number): number => {
|
||||||
|
return priceWithTax / (1 + taxRate / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate margin percentage
|
||||||
|
const calculateMargin = (price: number, cost: number): number => {
|
||||||
|
if (cost === 0) return 100;
|
||||||
|
return ((price - cost) / cost) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate markup percentage
|
||||||
|
const calculateMarkup = (price: number, cost: number): number => {
|
||||||
|
if (price === 0) return 0;
|
||||||
|
return ((price - cost) / price) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatPrice,
|
||||||
|
calculatePriceWithTax,
|
||||||
|
calculatePriceWithoutTax,
|
||||||
|
calculateMargin,
|
||||||
|
calculateMarkup,
|
||||||
|
};
|
||||||
|
}
|
||||||
133
src/features/products/hooks/useProducts.ts
Normal file
133
src/features/products/hooks/useProducts.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { productsApi } from '../api/products.api';
|
||||||
|
import type {
|
||||||
|
CreateProductDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
ProductSearchParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const QUERY_KEY = 'products';
|
||||||
|
|
||||||
|
export interface UseProductsOptions extends ProductSearchParams {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Products List Hook ====================
|
||||||
|
|
||||||
|
export function useProducts(options: UseProductsOptions = {}) {
|
||||||
|
const { enabled = true, ...params } = options;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'list', params],
|
||||||
|
queryFn: () => productsApi.getAll(params),
|
||||||
|
enabled,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Single Product Hook ====================
|
||||||
|
|
||||||
|
export function useProduct(id: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'detail', id],
|
||||||
|
queryFn: () => productsApi.getById(id as string),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Product by SKU Hook ====================
|
||||||
|
|
||||||
|
export function useProductBySku(sku: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'bySku', sku],
|
||||||
|
queryFn: () => productsApi.getBySku(sku as string),
|
||||||
|
enabled: !!sku,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Product by Barcode Hook ====================
|
||||||
|
|
||||||
|
export function useProductByBarcode(barcode: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'byBarcode', barcode],
|
||||||
|
queryFn: () => productsApi.getByBarcode(barcode as string),
|
||||||
|
enabled: !!barcode,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Sellable Products Hook ====================
|
||||||
|
|
||||||
|
export function useSellableProducts(limit = 50, offset = 0) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'sellable', limit, offset],
|
||||||
|
queryFn: () => productsApi.getSellable(limit, offset),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Purchasable Products Hook ====================
|
||||||
|
|
||||||
|
export function usePurchasableProducts(limit = 50, offset = 0) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'purchasable', limit, offset],
|
||||||
|
queryFn: () => productsApi.getPurchasable(limit, offset),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Product Mutations Hook ====================
|
||||||
|
|
||||||
|
export function useProductMutations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateProductDto) => productsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
|
||||||
|
productsApi.update(id, data),
|
||||||
|
onSuccess: (_: unknown, variables: { id: string; data: UpdateProductDto }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => productsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: createMutation.mutateAsync,
|
||||||
|
update: updateMutation.mutateAsync,
|
||||||
|
delete: deleteMutation.mutateAsync,
|
||||||
|
isCreating: createMutation.isPending,
|
||||||
|
isUpdating: updateMutation.isPending,
|
||||||
|
isDeleting: deleteMutation.isPending,
|
||||||
|
createError: createMutation.error,
|
||||||
|
updateError: updateMutation.error,
|
||||||
|
deleteError: deleteMutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Product Search Hook ====================
|
||||||
|
|
||||||
|
export function useProductSearch(searchTerm: string, options?: { limit?: number }) {
|
||||||
|
const { limit = 10 } = options || {};
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'search', searchTerm, limit],
|
||||||
|
queryFn: () => productsApi.getAll({ search: searchTerm, limit }),
|
||||||
|
enabled: searchTerm.length >= 2,
|
||||||
|
staleTime: 1000 * 30, // 30 seconds for search results
|
||||||
|
});
|
||||||
|
}
|
||||||
15
src/features/products/index.ts
Normal file
15
src/features/products/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// API
|
||||||
|
export { productsApi } from './api/products.api';
|
||||||
|
export { categoriesApi } from './api/categories.api';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export * from './components';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export * from './hooks';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
export * from './pages';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
387
src/features/products/pages/CategoriesPage.tsx
Normal file
387
src/features/products/pages/CategoriesPage.tsx
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Folder, Edit2, Trash2, Search } from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Badge } from '@components/atoms/Badge';
|
||||||
|
import { Input } from '@components/atoms/Input';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { FormField } from '@components/molecules/FormField';
|
||||||
|
import { Modal, ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useCategories, useCategoryMutations, useCategoryOptions } from '../hooks';
|
||||||
|
import { CategoryTree } from '../components';
|
||||||
|
import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, CategorySearchParams } from '../types';
|
||||||
|
|
||||||
|
const categorySchema = z.object({
|
||||||
|
code: z.string().min(1, 'Codigo es requerido').max(30, 'Maximo 30 caracteres'),
|
||||||
|
name: z.string().min(1, 'Nombre es requerido').max(100, 'Maximo 100 caracteres'),
|
||||||
|
description: z.string().max(500, 'Maximo 500 caracteres').optional(),
|
||||||
|
parentId: z.string().uuid().optional().or(z.literal('')),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CategoryFormData = z.infer<typeof categorySchema>;
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filters] = useState<CategorySearchParams>({
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [viewMode, setViewMode] = useState<'tree' | 'table'>('tree');
|
||||||
|
const [formModalOpen, setFormModalOpen] = useState(false);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<ProductCategory | null>(null);
|
||||||
|
const [, setParentIdForNew] = useState<string | undefined>();
|
||||||
|
const [categoryToDelete, setCategoryToDelete] = useState<ProductCategory | null>(null);
|
||||||
|
|
||||||
|
const queryParams: CategorySearchParams = {
|
||||||
|
...filters,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
offset: (page - 1) * (filters.limit || 50),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useCategories(queryParams);
|
||||||
|
const { create, update, delete: deleteCategory, isCreating, isUpdating, isDeleting } = useCategoryMutations();
|
||||||
|
const { options: categoryOptions } = useCategoryOptions();
|
||||||
|
|
||||||
|
const categories = data?.data || [];
|
||||||
|
const total = data?.total || 0;
|
||||||
|
const limit = filters.limit || 50;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<CategoryFormData>({
|
||||||
|
resolver: zodResolver(categorySchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
parentId: '',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreateModal = (parentId?: string) => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setParentIdForNew(parentId);
|
||||||
|
reset({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
parentId: parentId || '',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setFormModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (category: ProductCategory) => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setParentIdForNew(undefined);
|
||||||
|
reset({
|
||||||
|
code: category.code,
|
||||||
|
name: category.name,
|
||||||
|
description: category.description || '',
|
||||||
|
parentId: category.parentId || '',
|
||||||
|
isActive: category.isActive,
|
||||||
|
});
|
||||||
|
setFormModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (category: ProductCategory) => {
|
||||||
|
setCategoryToDelete(category);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: CategoryFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
description: data.description || undefined,
|
||||||
|
parentId: data.parentId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCategory) {
|
||||||
|
await update({ id: editingCategory.id, data: cleanData as UpdateCategoryDto });
|
||||||
|
} else {
|
||||||
|
await create(cleanData as CreateCategoryDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormModalOpen(false);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!categoryToDelete) return;
|
||||||
|
await deleteCategory(categoryToDelete.id);
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<ProductCategory>[] = [
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
header: 'Codigo',
|
||||||
|
sortable: true,
|
||||||
|
width: '120px',
|
||||||
|
render: (row) => <span className="font-mono">{row.code}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Nombre',
|
||||||
|
sortable: true,
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="h-4 w-4 text-yellow-500" />
|
||||||
|
<span>{row.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hierarchyPath',
|
||||||
|
header: 'Jerarquia',
|
||||||
|
render: (row) => (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{row.hierarchyPath || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hierarchyLevel',
|
||||||
|
header: 'Nivel',
|
||||||
|
align: 'center',
|
||||||
|
width: '80px',
|
||||||
|
render: (row) => <Badge variant="default">{row.hierarchyLevel}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
header: 'Estado',
|
||||||
|
width: '100px',
|
||||||
|
render: (row) => (
|
||||||
|
<Badge variant={row.isActive ? 'success' : 'default'}>
|
||||||
|
{row.isActive ? 'Activa' : 'Inactiva'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
width: '100px',
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditModal(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDeleteModal(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Categorias de Productos</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Organiza tus productos en categorias jerarquicas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex rounded-md border">
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 text-sm ${viewMode === 'tree' ? 'bg-gray-100' : ''}`}
|
||||||
|
onClick={() => setViewMode('tree')}
|
||||||
|
>
|
||||||
|
Arbol
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 text-sm ${viewMode === 'table' ? 'bg-gray-100' : ''}`}
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
>
|
||||||
|
Tabla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => openCreateModal()} leftIcon={<Plus className="h-4 w-4" />}>
|
||||||
|
Nueva Categoria
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{viewMode === 'tree' ? (
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<CategoryTree
|
||||||
|
onSelect={(cat) => openEditModal(cat)}
|
||||||
|
onAdd={openCreateModal}
|
||||||
|
onEdit={openEditModal}
|
||||||
|
onDelete={openDeleteModal}
|
||||||
|
showActions
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ayuda</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-gray-600">
|
||||||
|
<p>Las categorias te permiten organizar tus productos de forma jerarquica.</p>
|
||||||
|
<ul className="list-inside list-disc space-y-1">
|
||||||
|
<li>Haz clic en una categoria para editarla</li>
|
||||||
|
<li>Usa el menu de acciones para agregar subcategorias</li>
|
||||||
|
<li>Arrastra categorias para reorganizar (proximamente)</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Search */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar categorias..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable
|
||||||
|
data={categories}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No se encontraron categorias"
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={formModalOpen}
|
||||||
|
onClose={() => setFormModalOpen(false)}
|
||||||
|
title={editingCategory ? 'Editar Categoria' : 'Nueva Categoria'}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField label="Codigo" error={errors.code?.message} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('code')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Ej: ELEC"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Nombre" error={errors.name?.message} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('name')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Ej: Electronica"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Categoria Padre" error={errors.parentId?.message}>
|
||||||
|
<select
|
||||||
|
{...register('parentId')}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Sin categoria padre (raiz)</option>
|
||||||
|
{categoryOptions
|
||||||
|
.filter((opt: { value: string; label: string }) => opt.value !== editingCategory?.id)
|
||||||
|
.map((opt: { value: string; label: string }) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Descripcion" error={errors.description?.message}>
|
||||||
|
<textarea
|
||||||
|
{...register('description')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Descripcion opcional..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('isActive')}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Categoria activa</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setFormModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isCreating || isUpdating}>
|
||||||
|
{editingCategory ? 'Guardar' : 'Crear'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setCategoryToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Eliminar categoria"
|
||||||
|
message={`¿Estas seguro de eliminar "${categoryToDelete?.name}"? Los productos en esta categoria quedaran sin categoria.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
src/features/products/pages/ProductDetailPage.tsx
Normal file
420
src/features/products/pages/ProductDetailPage.tsx
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Package,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Tag,
|
||||||
|
DollarSign,
|
||||||
|
Barcode,
|
||||||
|
Box,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Badge } from '@components/atoms/Badge';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { Spinner } from '@components/atoms/Spinner';
|
||||||
|
import { useProduct, useProductMutations, usePricingHelpers } from '../hooks';
|
||||||
|
import { ProductForm, PricingTable } from '../components';
|
||||||
|
import type { Product, ProductType, UpdateProductDto } from '../types';
|
||||||
|
|
||||||
|
const productTypeLabels: Record<ProductType, string> = {
|
||||||
|
product: 'Producto',
|
||||||
|
service: 'Servicio',
|
||||||
|
consumable: 'Consumible',
|
||||||
|
kit: 'Kit',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: product, isLoading, error, refetch } = useProduct(id) as {
|
||||||
|
data: Product | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
};
|
||||||
|
const { update, delete: deleteProduct, isUpdating, isDeleting } = useProductMutations();
|
||||||
|
const { formatPrice, calculateMargin } = usePricingHelpers();
|
||||||
|
|
||||||
|
const handleUpdate = async (data: UpdateProductDto) => {
|
||||||
|
if (!id) return;
|
||||||
|
await update({ id, data });
|
||||||
|
setIsEditing(false);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
await deleteProduct(id);
|
||||||
|
navigate('/products');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
// Navigate to new product page with prefilled data
|
||||||
|
navigate('/products/new', { state: { duplicateFrom: product } });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !product) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Package className="h-12 w-12 text-gray-300" />
|
||||||
|
<p className="mt-2 text-gray-500">Producto no encontrado</p>
|
||||||
|
<Button variant="link" onClick={() => navigate('/products')} className="mt-4">
|
||||||
|
Volver a productos
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
leftIcon={<ArrowLeft className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Cancelar edicion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ProductForm
|
||||||
|
product={product}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onCancel={() => setIsEditing(false)}
|
||||||
|
isLoading={isUpdating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = product.cost > 0 ? calculateMargin(product.price, product.cost) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate('/products')}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="h-16 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-gray-100">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Package className="h-8 w-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
|
||||||
|
<Badge variant={product.isActive ? 'success' : 'default'}>
|
||||||
|
{product.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span className="font-mono">{product.sku}</span>
|
||||||
|
<Badge variant="default">{productTypeLabels[product.productType]}</Badge>
|
||||||
|
{product.category && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
{product.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleDuplicate} leftIcon={<Copy className="h-4 w-4" />}>
|
||||||
|
Duplicar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditing(true)} leftIcon={<Edit2 className="h-4 w-4" />}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteModalOpen(true)}
|
||||||
|
leftIcon={<Trash2 className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-green-100 p-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Precio de venta</p>
|
||||||
|
<p className="text-lg font-semibold">{formatPrice(product.price, product.currency)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-blue-100 p-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Costo</p>
|
||||||
|
<p className="text-lg font-semibold">{formatPrice(product.cost, product.currency)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`rounded-lg p-2 ${margin >= 0 ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||||
|
<Tag className={`h-5 w-5 ${margin >= 0 ? 'text-green-600' : 'text-red-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Margen</p>
|
||||||
|
<p className={`text-lg font-semibold ${margin >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{margin.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-purple-100 p-2">
|
||||||
|
<Box className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">IVA</p>
|
||||||
|
<p className="text-lg font-semibold">{product.taxRate}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultTab="details">
|
||||||
|
<TabList>
|
||||||
|
<Tab value="details">Detalles</Tab>
|
||||||
|
<Tab value="prices">Precios</Tab>
|
||||||
|
<Tab value="inventory">Inventario</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel value="details" className="space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informacion General</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">SKU</p>
|
||||||
|
<p className="mt-1 font-mono">{product.sku}</p>
|
||||||
|
</div>
|
||||||
|
{product.barcode && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Codigo de barras</p>
|
||||||
|
<p className="mt-1 flex items-center gap-2 font-mono">
|
||||||
|
<Barcode className="h-4 w-4 text-gray-400" />
|
||||||
|
{product.barcode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Tipo</p>
|
||||||
|
<p className="mt-1">{productTypeLabels[product.productType]}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Categoria</p>
|
||||||
|
<p className="mt-1">{product.category?.name || 'Sin categoria'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Unidad de medida</p>
|
||||||
|
<p className="mt-1">{product.uom}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Moneda</p>
|
||||||
|
<p className="mt-1">{product.currency}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{(product.description || product.shortDescription) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Descripcion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{product.shortDescription && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Descripcion corta</p>
|
||||||
|
<p className="mt-1">{product.shortDescription}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Descripcion completa</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap">{product.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuracion</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-3 w-3 rounded-full ${product.isActive ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{product.isActive ? 'Producto activo' : 'Producto inactivo'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-3 w-3 rounded-full ${product.isSellable ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{product.isSellable ? 'Disponible para venta' : 'No disponible para venta'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-3 w-3 rounded-full ${product.isPurchasable ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{product.isPurchasable ? 'Disponible para compra' : 'No disponible para compra'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metadatos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Creado: {new Date(product.createdAt).toLocaleDateString('es-MX')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Actualizado: {new Date(product.updatedAt).toLocaleDateString('es-MX')}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="prices">
|
||||||
|
<PricingTable
|
||||||
|
productId={product.id}
|
||||||
|
productPrice={product.price}
|
||||||
|
productCost={product.cost}
|
||||||
|
currency={product.currency}
|
||||||
|
onAddPrice={() => navigate(`/products/${product.id}/prices/new`)}
|
||||||
|
onEditPrice={(price) => navigate(`/products/${product.id}/prices/${price.id}`)}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="inventory">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuracion de Inventario</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Control de inventario</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{product.trackInventory ? 'Activado' : 'Desactivado'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Stock minimo</p>
|
||||||
|
<p className="mt-1">{product.minStock} {product.uom}</p>
|
||||||
|
</div>
|
||||||
|
{product.maxStock && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Stock maximo</p>
|
||||||
|
<p className="mt-1">{product.maxStock} {product.uom}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.reorderPoint && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Punto de reorden</p>
|
||||||
|
<p className="mt-1">{product.reorderPoint} {product.uom}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Tiempo de entrega</p>
|
||||||
|
<p className="mt-1">{product.leadTimeDays} dias</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => setDeleteModalOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Eliminar producto"
|
||||||
|
message={`¿Estas seguro de eliminar "${product.name}"? Esta accion no se puede deshacer.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
382
src/features/products/pages/ProductsPage.tsx
Normal file
382
src/features/products/pages/ProductsPage.tsx
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Search, Grid, List, Package } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Badge } from '@components/atoms/Badge';
|
||||||
|
import { Input } from '@components/atoms/Input';
|
||||||
|
import { Card, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { Select } from '@components/organisms/Select';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { useDebounce } from '@hooks/useDebounce';
|
||||||
|
import { useProducts, useProductMutations, useCategoryOptions, usePricingHelpers } from '../hooks';
|
||||||
|
import { ProductCard } from '../components';
|
||||||
|
import type { Product, ProductType, ProductSearchParams } from '../types';
|
||||||
|
|
||||||
|
const productTypeOptions = [
|
||||||
|
{ value: '', label: 'Todos los tipos' },
|
||||||
|
{ value: 'product', label: 'Productos' },
|
||||||
|
{ value: 'service', label: 'Servicios' },
|
||||||
|
{ value: 'consumable', label: 'Consumibles' },
|
||||||
|
{ value: 'kit', label: 'Kits' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: 'Todos' },
|
||||||
|
{ value: 'active', label: 'Activos' },
|
||||||
|
{ value: 'inactive', label: 'Inactivos' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ViewMode = 'table' | 'grid';
|
||||||
|
|
||||||
|
export function ProductsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filters, setFilters] = useState<ProductSearchParams>({
|
||||||
|
limit: 25,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [productToDelete, setProductToDelete] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||||
|
const { options: categoryOptions } = useCategoryOptions();
|
||||||
|
const { formatPrice } = usePricingHelpers();
|
||||||
|
|
||||||
|
const queryParams: ProductSearchParams = {
|
||||||
|
...filters,
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
offset: (page - 1) * (filters.limit || 25),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useProducts(queryParams);
|
||||||
|
const { delete: deleteProduct, isDeleting } = useProductMutations();
|
||||||
|
|
||||||
|
const products = data?.data || [];
|
||||||
|
const total = data?.total || 0;
|
||||||
|
const limit = filters.limit || 25;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback((newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((key: keyof ProductSearchParams, value: string) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value || undefined,
|
||||||
|
}));
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProductClick = useCallback(
|
||||||
|
(product: Product) => {
|
||||||
|
navigate(`/products/${product.id}`);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(product: Product) => {
|
||||||
|
navigate(`/products/${product.id}/edit`);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!productToDelete) return;
|
||||||
|
try {
|
||||||
|
await deleteProduct(productToDelete.id);
|
||||||
|
refetch();
|
||||||
|
} finally {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setProductToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteModal = (product: Product) => {
|
||||||
|
setProductToDelete(product);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Product>[] = [
|
||||||
|
{
|
||||||
|
key: 'sku',
|
||||||
|
header: 'SKU',
|
||||||
|
sortable: true,
|
||||||
|
width: '120px',
|
||||||
|
render: (row) => (
|
||||||
|
<span className="font-mono text-sm">{row.sku}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Producto',
|
||||||
|
sortable: true,
|
||||||
|
render: (row) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-gray-100">
|
||||||
|
{row.imageUrl ? (
|
||||||
|
<img src={row.imageUrl} alt={row.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Package className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{row.name}</div>
|
||||||
|
{row.barcode && (
|
||||||
|
<div className="text-xs text-gray-500">{row.barcode}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'productType',
|
||||||
|
header: 'Tipo',
|
||||||
|
width: '100px',
|
||||||
|
render: (row) => {
|
||||||
|
const typeLabels: Record<ProductType, string> = {
|
||||||
|
product: 'Producto',
|
||||||
|
service: 'Servicio',
|
||||||
|
consumable: 'Consumible',
|
||||||
|
kit: 'Kit',
|
||||||
|
};
|
||||||
|
return <Badge variant="default">{typeLabels[row.productType]}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
header: 'Categoria',
|
||||||
|
render: (row) => row.category?.name || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
header: 'Precio',
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
render: (row) => formatPrice(row.price, row.currency),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
header: 'Costo',
|
||||||
|
align: 'right',
|
||||||
|
render: (row) => formatPrice(row.cost, row.currency),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
header: 'Estado',
|
||||||
|
width: '100px',
|
||||||
|
render: (row) => (
|
||||||
|
<Badge variant={row.isActive ? 'success' : 'default'}>
|
||||||
|
{row.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Gestiona el catalogo de productos, servicios y kits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate('/products/new')} leftIcon={<Plus className="h-4 w-4" />}>
|
||||||
|
Nuevo Producto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre, SKU o codigo de barras..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
<Select
|
||||||
|
value={filters.productType || ''}
|
||||||
|
onChange={(value) => handleFilterChange('productType', value as string)}
|
||||||
|
options={productTypeOptions}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<Select
|
||||||
|
value={filters.categoryId || ''}
|
||||||
|
onChange={(value) => handleFilterChange('categoryId', value as string)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Todas las categorias' },
|
||||||
|
...categoryOptions.map((opt: { value: string; label: string }) => ({ value: opt.value, label: opt.label })),
|
||||||
|
]}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
filters.isActive === undefined
|
||||||
|
? ''
|
||||||
|
: filters.isActive
|
||||||
|
? 'active'
|
||||||
|
: 'inactive'
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleFilterChange(
|
||||||
|
'isActive',
|
||||||
|
value === '' ? '' : value === 'active' ? 'true' : 'false'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
options={statusOptions}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-md border">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center p-2 transition-colors',
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-gray-100 text-gray-900'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center p-2 transition-colors',
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-gray-100 text-gray-900'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
>
|
||||||
|
<Grid className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{error ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500">Error al cargar productos</p>
|
||||||
|
<Button variant="link" onClick={() => refetch()} className="mt-2">
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : viewMode === 'table' ? (
|
||||||
|
<DataTable
|
||||||
|
data={products}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No se encontraron productos"
|
||||||
|
onRowClick={handleProductClick}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
onLimitChange: (newLimit) => {
|
||||||
|
setFilters((prev) => ({ ...prev, limit: newLimit }));
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-gray-500">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Package className="h-12 w-12 text-gray-300" />
|
||||||
|
<p className="mt-2 text-gray-500">No se encontraron productos</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{products.map((product: Product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
onClick={handleProductClick}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={openDeleteModal}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination for grid view */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Pagina {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={deleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setProductToDelete(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Eliminar producto"
|
||||||
|
message={`¿Estas seguro de eliminar "${productToDelete?.name}"? Esta accion no se puede deshacer.`}
|
||||||
|
confirmText="Eliminar"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/features/products/pages/index.ts
Normal file
3
src/features/products/pages/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ProductsPage } from './ProductsPage';
|
||||||
|
export { ProductDetailPage } from './ProductDetailPage';
|
||||||
|
export { CategoriesPage } from './CategoriesPage';
|
||||||
279
src/features/products/types/index.ts
Normal file
279
src/features/products/types/index.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
// Product Types - Based on backend entities
|
||||||
|
|
||||||
|
// ==================== Product Types ====================
|
||||||
|
|
||||||
|
export type ProductType = 'product' | 'service' | 'consumable' | 'kit';
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
category?: ProductCategory | null;
|
||||||
|
sku: string;
|
||||||
|
barcode: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
shortDescription: string | null;
|
||||||
|
productType: ProductType;
|
||||||
|
price: number;
|
||||||
|
cost: number;
|
||||||
|
currency: string;
|
||||||
|
taxIncluded: boolean;
|
||||||
|
taxRate: number;
|
||||||
|
taxCode: string | null;
|
||||||
|
uom: string;
|
||||||
|
uomPurchase: string | null;
|
||||||
|
uomConversion: number;
|
||||||
|
trackInventory: boolean;
|
||||||
|
minStock: number;
|
||||||
|
maxStock: number | null;
|
||||||
|
reorderPoint: number | null;
|
||||||
|
leadTimeDays: number;
|
||||||
|
weight: number | null;
|
||||||
|
length: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
images: string[];
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
isSellable: boolean;
|
||||||
|
isPurchasable: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductDto {
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
shortDescription?: string;
|
||||||
|
barcode?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
productType?: ProductType;
|
||||||
|
price?: number;
|
||||||
|
cost?: number;
|
||||||
|
currency?: string;
|
||||||
|
taxRate?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
isSellable?: boolean;
|
||||||
|
isPurchasable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductDto {
|
||||||
|
sku?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
barcode?: string | null;
|
||||||
|
categoryId?: string | null;
|
||||||
|
productType?: ProductType;
|
||||||
|
price?: number;
|
||||||
|
cost?: number;
|
||||||
|
currency?: string;
|
||||||
|
taxRate?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
isSellable?: boolean;
|
||||||
|
isPurchasable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSearchParams {
|
||||||
|
search?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
productType?: ProductType;
|
||||||
|
isActive?: boolean;
|
||||||
|
isSellable?: boolean;
|
||||||
|
isPurchasable?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Category Types ====================
|
||||||
|
|
||||||
|
export interface ProductCategory {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
parent?: ProductCategory | null;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
hierarchyPath: string | null;
|
||||||
|
hierarchyLevel: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parentId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorySearchParams {
|
||||||
|
search?: string;
|
||||||
|
parentId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Variant Types ====================
|
||||||
|
|
||||||
|
export interface ProductVariant {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
product?: Product;
|
||||||
|
tenantId: string;
|
||||||
|
sku: string;
|
||||||
|
barcode: string | null;
|
||||||
|
name: string;
|
||||||
|
priceExtra: number;
|
||||||
|
costExtra: number;
|
||||||
|
stockQty: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Price Types ====================
|
||||||
|
|
||||||
|
export type PriceType = 'standard' | 'wholesale' | 'retail' | 'promo';
|
||||||
|
|
||||||
|
export interface ProductPrice {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
product?: Product;
|
||||||
|
priceType: PriceType;
|
||||||
|
priceListName: string | null;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
minQuantity: number;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePriceDto {
|
||||||
|
productId: string;
|
||||||
|
priceType?: PriceType;
|
||||||
|
priceListName?: string;
|
||||||
|
price: number;
|
||||||
|
currency?: string;
|
||||||
|
minQuantity?: number;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePriceDto {
|
||||||
|
priceType?: PriceType;
|
||||||
|
priceListName?: string | null;
|
||||||
|
price?: number;
|
||||||
|
currency?: string;
|
||||||
|
minQuantity?: number;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Attribute Types ====================
|
||||||
|
|
||||||
|
export type AttributeDisplayType = 'radio' | 'select' | 'color' | 'pills';
|
||||||
|
|
||||||
|
export interface ProductAttribute {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
displayType: AttributeDisplayType;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
values?: ProductAttributeValue[];
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductAttributeValue {
|
||||||
|
id: string;
|
||||||
|
attributeId: string;
|
||||||
|
attribute?: ProductAttribute;
|
||||||
|
code: string | null;
|
||||||
|
name: string;
|
||||||
|
htmlColor: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Supplier Types ====================
|
||||||
|
|
||||||
|
export interface ProductSupplier {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
product?: Product;
|
||||||
|
supplierId: string;
|
||||||
|
supplierSku: string | null;
|
||||||
|
supplierName: string | null;
|
||||||
|
purchasePrice: number | null;
|
||||||
|
currency: string;
|
||||||
|
minOrderQty: number;
|
||||||
|
leadTimeDays: number;
|
||||||
|
isPreferred: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Response Types ====================
|
||||||
|
|
||||||
|
export interface ProductsResponse {
|
||||||
|
data: Product[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoriesResponse {
|
||||||
|
data: ProductCategory[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricesResponse {
|
||||||
|
data: ProductPrice[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tree Node Types ====================
|
||||||
|
|
||||||
|
export interface CategoryTreeNode extends ProductCategory {
|
||||||
|
children: CategoryTreeNode[];
|
||||||
|
}
|
||||||
1
src/features/warehouses/api/index.ts
Normal file
1
src/features/warehouses/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { warehousesApi, locationsApi } from './warehouses.api';
|
||||||
161
src/features/warehouses/api/warehouses.api.ts
Normal file
161
src/features/warehouses/api/warehouses.api.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import api from '@services/api/axios-instance';
|
||||||
|
import type { ApiResponse } from '@shared/types/api.types';
|
||||||
|
import type {
|
||||||
|
Warehouse,
|
||||||
|
WarehouseLocation,
|
||||||
|
CreateWarehouseDto,
|
||||||
|
UpdateWarehouseDto,
|
||||||
|
CreateLocationDto,
|
||||||
|
UpdateLocationDto,
|
||||||
|
WarehouseFilters,
|
||||||
|
LocationFilters,
|
||||||
|
WarehousesResponse,
|
||||||
|
LocationsResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/v1/warehouses';
|
||||||
|
|
||||||
|
// ==================== Warehouses API ====================
|
||||||
|
|
||||||
|
export const warehousesApi = {
|
||||||
|
// List all warehouses with filters
|
||||||
|
list: async (filters?: WarehouseFilters): Promise<WarehousesResponse> => {
|
||||||
|
const response = await api.get<ApiResponse<Warehouse[]> & { total?: number }>(BASE_URL, {
|
||||||
|
params: filters,
|
||||||
|
});
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al obtener almacenes');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || response.data.data?.length || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get warehouse by ID
|
||||||
|
getById: async (id: string): Promise<Warehouse> => {
|
||||||
|
const response = await api.get<ApiResponse<Warehouse>>(`${BASE_URL}/${id}`);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Almacen no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get warehouse by code
|
||||||
|
getByCode: async (code: string): Promise<Warehouse> => {
|
||||||
|
const response = await api.get<ApiResponse<Warehouse>>(`${BASE_URL}/code/${code}`);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Almacen no encontrado');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get default warehouse
|
||||||
|
getDefault: async (): Promise<Warehouse> => {
|
||||||
|
const response = await api.get<ApiResponse<Warehouse>>(`${BASE_URL}/default`);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'No hay almacen por defecto');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get active warehouses
|
||||||
|
getActive: async (): Promise<Warehouse[]> => {
|
||||||
|
const response = await api.get<ApiResponse<Warehouse[]> & { total?: number }>(`${BASE_URL}/active`);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al obtener almacenes activos');
|
||||||
|
}
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create warehouse
|
||||||
|
create: async (data: CreateWarehouseDto): Promise<Warehouse> => {
|
||||||
|
const response = await api.post<ApiResponse<Warehouse>>(BASE_URL, data);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Error al crear almacen');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update warehouse
|
||||||
|
update: async (id: string, data: UpdateWarehouseDto): Promise<Warehouse> => {
|
||||||
|
const response = await api.patch<ApiResponse<Warehouse>>(`${BASE_URL}/${id}`, data);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Error al actualizar almacen');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete warehouse
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
const response = await api.delete<ApiResponse>(`${BASE_URL}/${id}`);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al eliminar almacen');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Locations API ====================
|
||||||
|
|
||||||
|
export const locationsApi = {
|
||||||
|
// List all locations with filters
|
||||||
|
list: async (filters?: LocationFilters): Promise<LocationsResponse> => {
|
||||||
|
const response = await api.get<ApiResponse<WarehouseLocation[]> & { total?: number }>(
|
||||||
|
`${BASE_URL}/locations`,
|
||||||
|
{ params: filters }
|
||||||
|
);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al obtener ubicaciones');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: response.data.data || [],
|
||||||
|
total: response.data.total || response.data.data?.length || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get location by ID
|
||||||
|
getById: async (id: string): Promise<WarehouseLocation> => {
|
||||||
|
const response = await api.get<ApiResponse<WarehouseLocation>>(`${BASE_URL}/locations/${id}`);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Ubicacion no encontrada');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get locations by warehouse
|
||||||
|
getByWarehouse: async (warehouseId: string): Promise<WarehouseLocation[]> => {
|
||||||
|
const response = await api.get<ApiResponse<WarehouseLocation[]> & { total?: number }>(
|
||||||
|
`${BASE_URL}/${warehouseId}/locations`
|
||||||
|
);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al obtener ubicaciones del almacen');
|
||||||
|
}
|
||||||
|
return response.data.data || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create location
|
||||||
|
create: async (data: CreateLocationDto): Promise<WarehouseLocation> => {
|
||||||
|
const response = await api.post<ApiResponse<WarehouseLocation>>(`${BASE_URL}/locations`, data);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Error al crear ubicacion');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update location
|
||||||
|
update: async (id: string, data: UpdateLocationDto): Promise<WarehouseLocation> => {
|
||||||
|
const response = await api.patch<ApiResponse<WarehouseLocation>>(`${BASE_URL}/locations/${id}`, data);
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
throw new Error(response.data.error || 'Error al actualizar ubicacion');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete location
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
const response = await api.delete<ApiResponse>(`${BASE_URL}/locations/${id}`);
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.error || 'Error al eliminar ubicacion');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
194
src/features/warehouses/components/LocationGrid.tsx
Normal file
194
src/features/warehouses/components/LocationGrid.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { LocationTypeBadge } from './LocationTypeBadge';
|
||||||
|
import type { WarehouseLocation } from '../types';
|
||||||
|
|
||||||
|
export interface LocationGridProps {
|
||||||
|
locations: WarehouseLocation[];
|
||||||
|
onLocationClick?: (location: WarehouseLocation) => void;
|
||||||
|
onLocationEdit?: (location: WarehouseLocation) => void;
|
||||||
|
onLocationDelete?: (location: WarehouseLocation) => void;
|
||||||
|
selectedLocationId?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationNode extends WarehouseLocation {
|
||||||
|
children: LocationNode[];
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(locations: WarehouseLocation[]): LocationNode[] {
|
||||||
|
const map = new Map<string, LocationNode>();
|
||||||
|
const roots: LocationNode[] = [];
|
||||||
|
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
map.set(loc.id, { ...loc, children: [], level: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
const node = map.get(loc.id)!;
|
||||||
|
if (loc.parentId && map.has(loc.parentId)) {
|
||||||
|
const parent = map.get(loc.parentId)!;
|
||||||
|
parent.children.push(node);
|
||||||
|
node.level = parent.level + 1;
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationItem({
|
||||||
|
location,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isSelected,
|
||||||
|
}: {
|
||||||
|
location: LocationNode;
|
||||||
|
onClick?: (location: WarehouseLocation) => void;
|
||||||
|
onEdit?: (location: WarehouseLocation) => void;
|
||||||
|
onDelete?: (location: WarehouseLocation) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-lg border p-3 transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600',
|
||||||
|
onClick && 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={() => onClick?.(location)}
|
||||||
|
style={{ marginLeft: `${location.level * 1.5}rem` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg text-sm font-medium',
|
||||||
|
location.isActive
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
|
||||||
|
: 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{location.code.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
|
<LocationTypeBadge type={location.locationType} />
|
||||||
|
{!location.isActive && (
|
||||||
|
<span className="text-xs text-gray-400">(Inactivo)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{location.code}
|
||||||
|
{location.barcode && ` | ${location.barcode}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{location.capacityUnits && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Cap: {location.capacityUnits.toLocaleString()} uds
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(location);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(location);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{location.children.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{location.children.map((child) => (
|
||||||
|
<LocationItem
|
||||||
|
key={child.id}
|
||||||
|
location={child}
|
||||||
|
onClick={onClick}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isSelected={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationGrid({
|
||||||
|
locations,
|
||||||
|
onLocationClick,
|
||||||
|
onLocationEdit,
|
||||||
|
onLocationDelete,
|
||||||
|
selectedLocationId,
|
||||||
|
className,
|
||||||
|
}: LocationGridProps) {
|
||||||
|
const tree = useMemo(() => buildTree(locations), [locations]);
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-12 w-12 text-gray-300 dark:text-gray-600">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No hay ubicaciones configuradas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{tree.map((location) => (
|
||||||
|
<LocationItem
|
||||||
|
key={location.id}
|
||||||
|
location={location}
|
||||||
|
onClick={onLocationClick}
|
||||||
|
onEdit={onLocationEdit}
|
||||||
|
onDelete={onLocationDelete}
|
||||||
|
isSelected={location.id === selectedLocationId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/features/warehouses/components/LocationTypeBadge.tsx
Normal file
31
src/features/warehouses/components/LocationTypeBadge.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { LocationType } from '../types';
|
||||||
|
|
||||||
|
export interface LocationTypeBadgeProps {
|
||||||
|
type: LocationType;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig: Record<LocationType, { label: string; color: string }> = {
|
||||||
|
zone: { label: 'Zona', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
|
||||||
|
aisle: { label: 'Pasillo', color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300' },
|
||||||
|
rack: { label: 'Rack', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
|
||||||
|
shelf: { label: 'Estante', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||||
|
bin: { label: 'Bin', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LocationTypeBadge({ type, className }: LocationTypeBadgeProps) {
|
||||||
|
const config = typeConfig[type] || typeConfig.shelf;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
config.color,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/features/warehouses/components/WarehouseCard.tsx
Normal file
152
src/features/warehouses/components/WarehouseCard.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { WarehouseTypeBadge } from './WarehouseTypeBadge';
|
||||||
|
import type { Warehouse } from '../types';
|
||||||
|
|
||||||
|
export interface WarehouseCardProps {
|
||||||
|
warehouse: Warehouse;
|
||||||
|
onClick?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseCard({
|
||||||
|
warehouse,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}: WarehouseCardProps) {
|
||||||
|
const address = [
|
||||||
|
warehouse.addressLine1,
|
||||||
|
warehouse.city,
|
||||||
|
warehouse.state,
|
||||||
|
warehouse.country,
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{warehouse.name}
|
||||||
|
</h3>
|
||||||
|
{warehouse.isDefault && (
|
||||||
|
<span className="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-300">
|
||||||
|
Por defecto
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Codigo: {warehouse.code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WarehouseTypeBadge type={warehouse.warehouseType} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-2 w-2 rounded-full',
|
||||||
|
warehouse.isActive ? 'bg-green-500' : 'bg-gray-300'
|
||||||
|
)}
|
||||||
|
title={warehouse.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warehouse.description && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||||
|
{warehouse.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-block w-4" title="Direccion">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warehouse.managerName && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-block w-4" title="Responsable">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{warehouse.managerName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(warehouse.capacityUnits || warehouse.capacityVolume || warehouse.capacityWeight) && (
|
||||||
|
<div className="mt-3 flex gap-4 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
|
{warehouse.capacityUnits && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityUnits.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Unidades</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warehouse.capacityVolume && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityVolume.toLocaleString()} m3
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Volumen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warehouse.capacityWeight && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityWeight.toLocaleString()} kg
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Peso</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<div className="mt-3 flex justify-end gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/features/warehouses/components/WarehouseLayout.tsx
Normal file
183
src/features/warehouses/components/WarehouseLayout.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { WarehouseLocation, LocationType } from '../types';
|
||||||
|
|
||||||
|
export interface WarehouseLayoutProps {
|
||||||
|
locations: WarehouseLocation[];
|
||||||
|
onLocationClick?: (location: WarehouseLocation) => void;
|
||||||
|
selectedLocationId?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationTypeColors: Record<LocationType, string> = {
|
||||||
|
zone: 'bg-indigo-100 border-indigo-300 dark:bg-indigo-900/30 dark:border-indigo-700',
|
||||||
|
aisle: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
||||||
|
rack: 'bg-teal-100 border-teal-300 dark:bg-teal-900/30 dark:border-teal-700',
|
||||||
|
shelf: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
||||||
|
bin: 'bg-gray-100 border-gray-300 dark:bg-gray-700/50 dark:border-gray-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LayoutNode {
|
||||||
|
location: WarehouseLocation;
|
||||||
|
children: LayoutNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLayoutTree(locations: WarehouseLocation[]): LayoutNode[] {
|
||||||
|
const map = new Map<string, LayoutNode>();
|
||||||
|
const roots: LayoutNode[] = [];
|
||||||
|
|
||||||
|
// Sort by location type priority
|
||||||
|
const typePriority: Record<LocationType, number> = {
|
||||||
|
zone: 0,
|
||||||
|
aisle: 1,
|
||||||
|
rack: 2,
|
||||||
|
shelf: 3,
|
||||||
|
bin: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...locations].sort((a, b) => {
|
||||||
|
return typePriority[a.locationType] - typePriority[b.locationType];
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((loc) => {
|
||||||
|
map.set(loc.id, { location: loc, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((loc) => {
|
||||||
|
const node = map.get(loc.id)!;
|
||||||
|
if (loc.parentId && map.has(loc.parentId)) {
|
||||||
|
const parent = map.get(loc.parentId)!;
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayoutCell({
|
||||||
|
node,
|
||||||
|
onClick,
|
||||||
|
isSelected,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
node: LayoutNode;
|
||||||
|
onClick?: (location: WarehouseLocation) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
const { location, children } = node;
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded border-2 p-2 transition-all',
|
||||||
|
locationTypeColors[location.locationType],
|
||||||
|
isSelected && 'ring-2 ring-primary-500 ring-offset-1',
|
||||||
|
!location.isActive && 'opacity-50',
|
||||||
|
onClick && 'cursor-pointer hover:shadow-md',
|
||||||
|
depth === 0 && 'min-h-[120px]'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(location);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{location.code}
|
||||||
|
</span>
|
||||||
|
{!location.isActive && (
|
||||||
|
<span className="text-[10px] text-gray-400">Inactivo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{location.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-2 grid gap-1',
|
||||||
|
children.length === 1 && 'grid-cols-1',
|
||||||
|
children.length === 2 && 'grid-cols-2',
|
||||||
|
children.length >= 3 && 'grid-cols-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children.map((child) => (
|
||||||
|
<LayoutCell
|
||||||
|
key={child.location.id}
|
||||||
|
node={child}
|
||||||
|
onClick={onClick}
|
||||||
|
isSelected={false}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseLayout({
|
||||||
|
locations,
|
||||||
|
onLocationClick,
|
||||||
|
selectedLocationId,
|
||||||
|
className,
|
||||||
|
}: WarehouseLayoutProps) {
|
||||||
|
const tree = useMemo(() => buildLayoutTree(locations), [locations]);
|
||||||
|
|
||||||
|
if (locations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-12 w-12 text-gray-300 dark:text-gray-600">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No hay layout configurado
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Agregue ubicaciones para visualizar el layout
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-3 rounded-lg bg-gray-50 p-2 dark:bg-gray-800/50">
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Leyenda:</span>
|
||||||
|
{Object.entries(locationTypeColors).map(([type, color]) => (
|
||||||
|
<div key={type} className="flex items-center gap-1">
|
||||||
|
<span className={cn('h-3 w-3 rounded border', color)} />
|
||||||
|
<span className="text-xs capitalize text-gray-600 dark:text-gray-400">
|
||||||
|
{type === 'zone' ? 'Zona' : type === 'aisle' ? 'Pasillo' : type === 'rack' ? 'Rack' : type === 'shelf' ? 'Estante' : 'Bin'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout Grid */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
tree.length === 1 && 'grid-cols-1',
|
||||||
|
tree.length === 2 && 'grid-cols-2',
|
||||||
|
tree.length >= 3 && 'grid-cols-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tree.map((node) => (
|
||||||
|
<LayoutCell
|
||||||
|
key={node.location.id}
|
||||||
|
node={node}
|
||||||
|
onClick={onLocationClick}
|
||||||
|
isSelected={node.location.id === selectedLocationId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/features/warehouses/components/WarehouseTypeBadge.tsx
Normal file
31
src/features/warehouses/components/WarehouseTypeBadge.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { WarehouseType } from '../types';
|
||||||
|
|
||||||
|
export interface WarehouseTypeBadgeProps {
|
||||||
|
type: WarehouseType;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig: Record<WarehouseType, { label: string; color: string }> = {
|
||||||
|
standard: { label: 'Estandar', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||||
|
transit: { label: 'Transito', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||||
|
returns: { label: 'Devoluciones', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
|
||||||
|
quarantine: { label: 'Cuarentena', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||||
|
virtual: { label: 'Virtual', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WarehouseTypeBadge({ type, className }: WarehouseTypeBadgeProps) {
|
||||||
|
const config = typeConfig[type] || typeConfig.standard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
config.color,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/features/warehouses/components/ZoneCard.tsx
Normal file
115
src/features/warehouses/components/ZoneCard.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { WarehouseZone, ZoneType } from '../types';
|
||||||
|
|
||||||
|
export interface ZoneCardProps {
|
||||||
|
zone: WarehouseZone;
|
||||||
|
onClick?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneTypeConfig: Record<ZoneType, { label: string; icon: string }> = {
|
||||||
|
storage: { label: 'Almacenamiento', icon: 'M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z' },
|
||||||
|
picking: { label: 'Picking', icon: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z' },
|
||||||
|
packing: { label: 'Empaque', icon: 'M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25' },
|
||||||
|
shipping: { label: 'Envio', icon: 'M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.125-.504 1.125-1.125V11.25a.75.75 0 00-.75-.75h-1.875m-4.5 4.5v-4.5m0 0H21.75m-1.875 0h-3.188l-1.876-2.858a.75.75 0 00-.638-.392H6.375a.75.75 0 00-.621.331l-1.5 2.75a.75.75 0 00-.108.395V13.5m3.375 4.5v-4.5m0 0h12m-7.875-3h8.625a.75.75 0 01.75.75v1.5a.75.75 0 01-.75.75h-8.625a.75.75 0 01-.75-.75v-1.5a.75.75 0 01.75-.75z' },
|
||||||
|
receiving: { label: 'Recepcion', icon: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3' },
|
||||||
|
quarantine: { label: 'Cuarentena', icon: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ZoneCard({
|
||||||
|
zone,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
className,
|
||||||
|
}: ZoneCardProps) {
|
||||||
|
const config = zoneTypeConfig[zone.zoneType] || zoneTypeConfig.storage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-4 transition-all',
|
||||||
|
zone.isActive
|
||||||
|
? 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'
|
||||||
|
: 'border-gray-100 bg-gray-50 opacity-60 dark:border-gray-800 dark:bg-gray-900',
|
||||||
|
onClick && 'cursor-pointer hover:shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
style={zone.color ? { borderLeftColor: zone.color, borderLeftWidth: '4px' } : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: zone.color ? `${zone.color}20` : undefined }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke={zone.color || 'currentColor'}
|
||||||
|
className="h-5 w-5 text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d={config.icon} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{zone.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{zone.code} | {config.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-2 w-2 rounded-full',
|
||||||
|
zone.isActive ? 'bg-green-500' : 'bg-gray-300'
|
||||||
|
)}
|
||||||
|
title={zone.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zone.description && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||||
|
{zone.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(onEdit || onDelete) && (
|
||||||
|
<div className="mt-3 flex justify-end gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/features/warehouses/components/index.ts
Normal file
11
src/features/warehouses/components/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Badges
|
||||||
|
export { WarehouseTypeBadge, type WarehouseTypeBadgeProps } from './WarehouseTypeBadge';
|
||||||
|
export { LocationTypeBadge, type LocationTypeBadgeProps } from './LocationTypeBadge';
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
export { WarehouseCard, type WarehouseCardProps } from './WarehouseCard';
|
||||||
|
export { ZoneCard, type ZoneCardProps } from './ZoneCard';
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
export { LocationGrid, type LocationGridProps } from './LocationGrid';
|
||||||
|
export { WarehouseLayout, type WarehouseLayoutProps } from './WarehouseLayout';
|
||||||
17
src/features/warehouses/hooks/index.ts
Normal file
17
src/features/warehouses/hooks/index.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Warehouses hooks
|
||||||
|
export {
|
||||||
|
useWarehouses,
|
||||||
|
useWarehouse,
|
||||||
|
useActiveWarehouses,
|
||||||
|
useDefaultWarehouse,
|
||||||
|
} from './useWarehouses';
|
||||||
|
export type { UseWarehousesOptions } from './useWarehouses';
|
||||||
|
|
||||||
|
// Locations hooks
|
||||||
|
export {
|
||||||
|
useLocations,
|
||||||
|
useAllLocations,
|
||||||
|
useLocation,
|
||||||
|
useLocationTree,
|
||||||
|
} from './useLocations';
|
||||||
|
export type { UseLocationsOptions } from './useLocations';
|
||||||
274
src/features/warehouses/hooks/useLocations.ts
Normal file
274
src/features/warehouses/hooks/useLocations.ts
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { locationsApi } from '../api';
|
||||||
|
import type {
|
||||||
|
WarehouseLocation,
|
||||||
|
CreateLocationDto,
|
||||||
|
UpdateLocationDto,
|
||||||
|
LocationFilters,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ==================== Locations List Hook ====================
|
||||||
|
|
||||||
|
export interface UseLocationsOptions {
|
||||||
|
filters?: LocationFilters;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocations(warehouseId: string | null, options: UseLocationsOptions = {}) {
|
||||||
|
const { filters, autoFetch = true } = options;
|
||||||
|
const [locations, setLocations] = useState<WarehouseLocation[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchLocations = useCallback(async () => {
|
||||||
|
if (!warehouseId) {
|
||||||
|
setLocations([]);
|
||||||
|
setTotal(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await locationsApi.getByWarehouse(warehouseId);
|
||||||
|
// Apply client-side filters if provided
|
||||||
|
let filtered = data;
|
||||||
|
if (filters?.locationType) {
|
||||||
|
filtered = filtered.filter((loc) => loc.locationType === filters.locationType);
|
||||||
|
}
|
||||||
|
if (filters?.isActive !== undefined) {
|
||||||
|
filtered = filtered.filter((loc) => loc.isActive === filters.isActive);
|
||||||
|
}
|
||||||
|
if (filters?.parentId) {
|
||||||
|
filtered = filtered.filter((loc) => loc.parentId === filters.parentId);
|
||||||
|
}
|
||||||
|
setLocations(filtered);
|
||||||
|
setTotal(filtered.length);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar ubicaciones');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [warehouseId, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchLocations();
|
||||||
|
}
|
||||||
|
}, [fetchLocations, autoFetch]);
|
||||||
|
|
||||||
|
const createLocation = useCallback(async (data: CreateLocationDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const newLocation = await locationsApi.create(data);
|
||||||
|
await fetchLocations();
|
||||||
|
return newLocation;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al crear ubicacion';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchLocations]);
|
||||||
|
|
||||||
|
const updateLocation = useCallback(async (id: string, data: UpdateLocationDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await locationsApi.update(id, data);
|
||||||
|
await fetchLocations();
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al actualizar ubicacion';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchLocations]);
|
||||||
|
|
||||||
|
const deleteLocation = useCallback(async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await locationsApi.delete(id);
|
||||||
|
await fetchLocations();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al eliminar ubicacion';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchLocations]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchLocations,
|
||||||
|
createLocation,
|
||||||
|
updateLocation,
|
||||||
|
deleteLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== All Locations Hook (with filters) ====================
|
||||||
|
|
||||||
|
export function useAllLocations(options: UseLocationsOptions = {}) {
|
||||||
|
const { filters, autoFetch = true } = options;
|
||||||
|
const [locations, setLocations] = useState<WarehouseLocation[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchLocations = useCallback(async (customFilters?: LocationFilters) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await locationsApi.list(customFilters ?? filters);
|
||||||
|
setLocations(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar ubicaciones');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchLocations();
|
||||||
|
}
|
||||||
|
}, [fetchLocations, autoFetch]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchLocations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Single Location Hook ====================
|
||||||
|
|
||||||
|
export function useLocation(locationId: string | null) {
|
||||||
|
const [location, setLocation] = useState<WarehouseLocation | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchLocation = useCallback(async () => {
|
||||||
|
if (!locationId) {
|
||||||
|
setLocation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await locationsApi.getById(locationId);
|
||||||
|
setLocation(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar ubicacion');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [locationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLocation();
|
||||||
|
}, [fetchLocation]);
|
||||||
|
|
||||||
|
const updateLocation = useCallback(async (data: UpdateLocationDto) => {
|
||||||
|
if (!locationId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await locationsApi.update(locationId, data);
|
||||||
|
setLocation(updated);
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al actualizar ubicacion';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [locationId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
location,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchLocation,
|
||||||
|
updateLocation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Location Tree Hook ====================
|
||||||
|
|
||||||
|
interface LocationNode extends WarehouseLocation {
|
||||||
|
children: LocationNode[];
|
||||||
|
level: number;
|
||||||
|
fullPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocationTree(locations: WarehouseLocation[]): LocationNode[] {
|
||||||
|
const locationMap = new Map<string, LocationNode>();
|
||||||
|
const roots: LocationNode[] = [];
|
||||||
|
|
||||||
|
// First pass: create nodes
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
locationMap.set(loc.id, {
|
||||||
|
...loc,
|
||||||
|
children: [],
|
||||||
|
level: 0,
|
||||||
|
fullPath: loc.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: build hierarchy
|
||||||
|
locations.forEach((loc) => {
|
||||||
|
const node = locationMap.get(loc.id)!;
|
||||||
|
if (loc.parentId && locationMap.has(loc.parentId)) {
|
||||||
|
const parent = locationMap.get(loc.parentId)!;
|
||||||
|
parent.children.push(node);
|
||||||
|
node.level = parent.level + 1;
|
||||||
|
node.fullPath = `${parent.fullPath} > ${loc.name}`;
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTree(nodes: LocationNode[], result: LocationNode[] = []): LocationNode[] {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
result.push(node);
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
flattenTree(node.children, result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocationTree(warehouseId: string | null) {
|
||||||
|
const { locations, isLoading, error, refresh } = useLocations(warehouseId);
|
||||||
|
|
||||||
|
const tree = buildLocationTree(locations);
|
||||||
|
const flatLocations = flattenTree(tree);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
flatLocations,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
220
src/features/warehouses/hooks/useWarehouses.ts
Normal file
220
src/features/warehouses/hooks/useWarehouses.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { warehousesApi } from '../api';
|
||||||
|
import type {
|
||||||
|
Warehouse,
|
||||||
|
CreateWarehouseDto,
|
||||||
|
UpdateWarehouseDto,
|
||||||
|
WarehouseFilters,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ==================== Warehouses List Hook ====================
|
||||||
|
|
||||||
|
export interface UseWarehousesOptions {
|
||||||
|
filters?: WarehouseFilters;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWarehouses(options: UseWarehousesOptions = {}) {
|
||||||
|
const { filters, autoFetch = true } = options;
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchWarehouses = useCallback(async (customFilters?: WarehouseFilters) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await warehousesApi.list(customFilters ?? filters);
|
||||||
|
setWarehouses(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar almacenes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchWarehouses();
|
||||||
|
}
|
||||||
|
}, [fetchWarehouses, autoFetch]);
|
||||||
|
|
||||||
|
const createWarehouse = useCallback(async (data: CreateWarehouseDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const newWarehouse = await warehousesApi.create(data);
|
||||||
|
await fetchWarehouses();
|
||||||
|
return newWarehouse;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al crear almacen';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchWarehouses]);
|
||||||
|
|
||||||
|
const updateWarehouse = useCallback(async (id: string, data: UpdateWarehouseDto) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await warehousesApi.update(id, data);
|
||||||
|
await fetchWarehouses();
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al actualizar almacen';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchWarehouses]);
|
||||||
|
|
||||||
|
const deleteWarehouse = useCallback(async (id: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await warehousesApi.delete(id);
|
||||||
|
await fetchWarehouses();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al eliminar almacen';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [fetchWarehouses]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
warehouses,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchWarehouses,
|
||||||
|
createWarehouse,
|
||||||
|
updateWarehouse,
|
||||||
|
deleteWarehouse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Single Warehouse Hook ====================
|
||||||
|
|
||||||
|
export function useWarehouse(warehouseId: string | null) {
|
||||||
|
const [warehouse, setWarehouse] = useState<Warehouse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchWarehouse = useCallback(async () => {
|
||||||
|
if (!warehouseId) {
|
||||||
|
setWarehouse(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await warehousesApi.getById(warehouseId);
|
||||||
|
setWarehouse(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar almacen');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [warehouseId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWarehouse();
|
||||||
|
}, [fetchWarehouse]);
|
||||||
|
|
||||||
|
const updateWarehouse = useCallback(async (data: UpdateWarehouseDto) => {
|
||||||
|
if (!warehouseId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await warehousesApi.update(warehouseId, data);
|
||||||
|
setWarehouse(updated);
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Error al actualizar almacen';
|
||||||
|
setError(errorMsg);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [warehouseId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
warehouse,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchWarehouse,
|
||||||
|
updateWarehouse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Active Warehouses Hook ====================
|
||||||
|
|
||||||
|
export function useActiveWarehouses() {
|
||||||
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchActiveWarehouses = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await warehousesApi.getActive();
|
||||||
|
setWarehouses(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar almacenes activos');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActiveWarehouses();
|
||||||
|
}, [fetchActiveWarehouses]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
warehouses,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchActiveWarehouses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Default Warehouse Hook ====================
|
||||||
|
|
||||||
|
export function useDefaultWarehouse() {
|
||||||
|
const [warehouse, setWarehouse] = useState<Warehouse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchDefaultWarehouse = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await warehousesApi.getDefault();
|
||||||
|
setWarehouse(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Error al cargar almacen por defecto');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDefaultWarehouse();
|
||||||
|
}, [fetchDefaultWarehouse]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
warehouse,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchDefaultWarehouse,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/features/warehouses/index.ts
Normal file
65
src/features/warehouses/index.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// API
|
||||||
|
export { warehousesApi, locationsApi } from './api';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
Warehouse,
|
||||||
|
WarehouseType,
|
||||||
|
WarehouseSettings,
|
||||||
|
CreateWarehouseDto,
|
||||||
|
UpdateWarehouseDto,
|
||||||
|
WarehouseFilters,
|
||||||
|
WarehouseLocation,
|
||||||
|
LocationType,
|
||||||
|
TemperatureRange,
|
||||||
|
HumidityRange,
|
||||||
|
CreateLocationDto,
|
||||||
|
UpdateLocationDto,
|
||||||
|
LocationFilters,
|
||||||
|
WarehouseZone,
|
||||||
|
ZoneType,
|
||||||
|
CreateZoneDto,
|
||||||
|
UpdateZoneDto,
|
||||||
|
WarehousesResponse,
|
||||||
|
LocationsResponse,
|
||||||
|
ZonesResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useWarehouses,
|
||||||
|
useWarehouse,
|
||||||
|
useActiveWarehouses,
|
||||||
|
useDefaultWarehouse,
|
||||||
|
useLocations,
|
||||||
|
useAllLocations,
|
||||||
|
useLocation,
|
||||||
|
useLocationTree,
|
||||||
|
} from './hooks';
|
||||||
|
export type { UseWarehousesOptions, UseLocationsOptions } from './hooks';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export {
|
||||||
|
WarehouseTypeBadge,
|
||||||
|
LocationTypeBadge,
|
||||||
|
WarehouseCard,
|
||||||
|
ZoneCard,
|
||||||
|
LocationGrid,
|
||||||
|
WarehouseLayout,
|
||||||
|
} from './components';
|
||||||
|
export type {
|
||||||
|
WarehouseTypeBadgeProps,
|
||||||
|
LocationTypeBadgeProps,
|
||||||
|
WarehouseCardProps,
|
||||||
|
ZoneCardProps,
|
||||||
|
LocationGridProps,
|
||||||
|
WarehouseLayoutProps,
|
||||||
|
} from './components';
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
export {
|
||||||
|
WarehousesPage,
|
||||||
|
WarehouseDetailPage,
|
||||||
|
LocationsPage,
|
||||||
|
ZonesPage,
|
||||||
|
} from './pages';
|
||||||
172
src/features/warehouses/pages/LocationsPage.tsx
Normal file
172
src/features/warehouses/pages/LocationsPage.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { useActiveWarehouses, useAllLocations } from '../hooks';
|
||||||
|
import { LocationGrid, LocationTypeBadge } from '../components';
|
||||||
|
import type { WarehouseLocation, LocationType } from '../types';
|
||||||
|
|
||||||
|
export function LocationsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warehouses, isLoading: loadingWarehouses } = useActiveWarehouses();
|
||||||
|
const [selectedWarehouseId, setSelectedWarehouseId] = useState<string>('');
|
||||||
|
const [selectedType, setSelectedType] = useState<LocationType | ''>('');
|
||||||
|
|
||||||
|
const { locations, isLoading: loadingLocations, error } = useAllLocations({
|
||||||
|
filters: {
|
||||||
|
warehouseId: selectedWarehouseId || undefined,
|
||||||
|
locationType: selectedType || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLocations = useMemo(() => {
|
||||||
|
let result = locations;
|
||||||
|
if (selectedWarehouseId) {
|
||||||
|
result = result.filter((loc) => loc.warehouseId === selectedWarehouseId);
|
||||||
|
}
|
||||||
|
if (selectedType) {
|
||||||
|
result = result.filter((loc) => loc.locationType === selectedType);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [locations, selectedWarehouseId, selectedType]);
|
||||||
|
|
||||||
|
const handleLocationClick = useCallback((location: WarehouseLocation) => {
|
||||||
|
navigate(`/warehouses/${location.warehouseId}/locations/${location.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleLocationEdit = useCallback((location: WarehouseLocation) => {
|
||||||
|
navigate(`/warehouses/${location.warehouseId}/locations/${location.id}/edit`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (selectedWarehouseId) {
|
||||||
|
navigate(`/warehouses/${selectedWarehouseId}/locations/new`);
|
||||||
|
} else if (warehouses.length > 0 && warehouses[0]) {
|
||||||
|
navigate(`/warehouses/${warehouses[0].id}/locations/new`);
|
||||||
|
}
|
||||||
|
}, [navigate, selectedWarehouseId, warehouses]);
|
||||||
|
|
||||||
|
const locationTypes: { value: LocationType | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'Todos los tipos' },
|
||||||
|
{ value: 'zone', label: 'Zona' },
|
||||||
|
{ value: 'aisle', label: 'Pasillo' },
|
||||||
|
{ value: 'rack', label: 'Rack' },
|
||||||
|
{ value: 'shelf', label: 'Estante' },
|
||||||
|
{ value: 'bin', label: 'Bin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading = loadingWarehouses || loadingLocations;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Ubicaciones
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Gestiona las ubicaciones de todos los almacenes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} disabled={warehouses.length === 0}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="mr-2 h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nueva Ubicacion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Almacen
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedWarehouseId}
|
||||||
|
onChange={(e) => setSelectedWarehouseId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>
|
||||||
|
{wh.code} - {wh.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Tipo de ubicacion
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value as LocationType | '')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{locationTypes.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||||
|
{locationTypes.slice(1).map((type) => {
|
||||||
|
const count = locations.filter((loc) => loc.locationType === type.value).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={type.value}
|
||||||
|
className="cursor-pointer rounded-lg bg-white p-4 shadow transition-shadow hover:shadow-md dark:bg-gray-800"
|
||||||
|
onClick={() => setSelectedType(type.value as LocationType)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<LocationTypeBadge type={type.value as LocationType} />
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<svg className="h-8 w-8 animate-spin text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Locations */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{filteredLocations.length} ubicaciones encontradas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LocationGrid
|
||||||
|
locations={filteredLocations}
|
||||||
|
onLocationClick={handleLocationClick}
|
||||||
|
onLocationEdit={handleLocationEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
src/features/warehouses/pages/WarehouseDetailPage.tsx
Normal file
274
src/features/warehouses/pages/WarehouseDetailPage.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { useWarehouse, useLocations } from '../hooks';
|
||||||
|
import { WarehouseTypeBadge, LocationGrid, WarehouseLayout } from '../components';
|
||||||
|
import type { WarehouseLocation } from '../types';
|
||||||
|
|
||||||
|
type ViewMode = 'list' | 'layout';
|
||||||
|
|
||||||
|
export function WarehouseDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warehouse, isLoading: loadingWarehouse, error: warehouseError } = useWarehouse(id ?? null);
|
||||||
|
const {
|
||||||
|
locations,
|
||||||
|
isLoading: loadingLocations,
|
||||||
|
deleteLocation,
|
||||||
|
} = useLocations(id ?? null);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [selectedLocationId, setSelectedLocationId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
navigate('/warehouses');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
navigate(`/warehouses/${id}/edit`);
|
||||||
|
}, [navigate, id]);
|
||||||
|
|
||||||
|
const handleAddLocation = useCallback(() => {
|
||||||
|
navigate(`/warehouses/${id}/locations/new`);
|
||||||
|
}, [navigate, id]);
|
||||||
|
|
||||||
|
const handleLocationClick = useCallback((location: WarehouseLocation) => {
|
||||||
|
setSelectedLocationId(location.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLocationEdit = useCallback((location: WarehouseLocation) => {
|
||||||
|
navigate(`/warehouses/${id}/locations/${location.id}/edit`);
|
||||||
|
}, [navigate, id]);
|
||||||
|
|
||||||
|
const handleLocationDelete = useCallback(async (location: WarehouseLocation) => {
|
||||||
|
if (!confirm(`Esta seguro de eliminar la ubicacion "${location.name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteLocation(location.id);
|
||||||
|
}, [deleteLocation]);
|
||||||
|
|
||||||
|
if (warehouseError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<p className="text-red-600 dark:text-red-400">{warehouseError}</p>
|
||||||
|
<Button onClick={handleBack} className="mt-4">
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingWarehouse || !warehouse) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<svg className="h-8 w-8 animate-spin text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = [
|
||||||
|
warehouse.addressLine1,
|
||||||
|
warehouse.addressLine2,
|
||||||
|
warehouse.city,
|
||||||
|
warehouse.state,
|
||||||
|
warehouse.postalCode,
|
||||||
|
warehouse.country,
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{warehouse.name}
|
||||||
|
</h1>
|
||||||
|
<WarehouseTypeBadge type={warehouse.warehouseType} />
|
||||||
|
{warehouse.isDefault && (
|
||||||
|
<span className="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-300">
|
||||||
|
Por defecto
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${warehouse.isActive ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||||
|
title={warehouse.isActive ? 'Activo' : 'Inactivo'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Codigo: {warehouse.code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleEdit} variant="outline">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="mr-2 h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Details Card */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Detalles</h3>
|
||||||
|
<dl className="mt-4 space-y-3">
|
||||||
|
{warehouse.description && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Descripcion</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-white">{warehouse.description}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{address && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Direccion</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-white">{address}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warehouse.managerName && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Responsable</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-white">{warehouse.managerName}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warehouse.phone && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Telefono</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-white">{warehouse.phone}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warehouse.email && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Email</dt>
|
||||||
|
<dd className="text-sm text-gray-900 dark:text-white">{warehouse.email}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity Card */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Capacidad</h3>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityUnits?.toLocaleString() ?? '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Unidades</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityVolume ? `${warehouse.capacityVolume} m3` : '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Volumen</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{warehouse.capacityWeight ? `${warehouse.capacityWeight} kg` : '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Peso</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Card */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Configuracion</h3>
|
||||||
|
<dl className="mt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Permitir stock negativo</dt>
|
||||||
|
<dd>
|
||||||
|
<span className={`rounded px-2 py-1 text-xs font-medium ${warehouse.settings?.allowNegative ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{warehouse.settings?.allowNegative ? 'Si' : 'No'}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<dt className="text-sm text-gray-500 dark:text-gray-400">Reorden automatico</dt>
|
||||||
|
<dd>
|
||||||
|
<span className={`rounded px-2 py-1 text-xs font-medium ${warehouse.settings?.autoReorder ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{warehouse.settings?.autoReorder ? 'Si' : 'No'}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Locations Section */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
Ubicaciones
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{locations.length} ubicaciones configuradas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`px-3 py-1.5 text-sm ${viewMode === 'list' ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300' : 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
Lista
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('layout')}
|
||||||
|
className={`px-3 py-1.5 text-sm ${viewMode === 'layout' ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300' : 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
Layout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddLocation} size="sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="mr-1 h-4 w-4">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nueva Ubicacion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{loadingLocations ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<svg className="h-6 w-6 animate-spin text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'list' ? (
|
||||||
|
<LocationGrid
|
||||||
|
locations={locations}
|
||||||
|
onLocationClick={handleLocationClick}
|
||||||
|
onLocationEdit={handleLocationEdit}
|
||||||
|
onLocationDelete={handleLocationDelete}
|
||||||
|
selectedLocationId={selectedLocationId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WarehouseLayout
|
||||||
|
locations={locations}
|
||||||
|
onLocationClick={handleLocationClick}
|
||||||
|
selectedLocationId={selectedLocationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/features/warehouses/pages/WarehousesPage.tsx
Normal file
137
src/features/warehouses/pages/WarehousesPage.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { useWarehouses } from '../hooks';
|
||||||
|
import { WarehouseCard } from '../components';
|
||||||
|
import type { Warehouse } from '../types';
|
||||||
|
|
||||||
|
export function WarehousesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warehouses, isLoading, error, refresh, deleteWarehouse } = useWarehouses();
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleWarehouseClick = useCallback((warehouse: Warehouse) => {
|
||||||
|
navigate(`/warehouses/${warehouse.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback((warehouse: Warehouse) => {
|
||||||
|
navigate(`/warehouses/${warehouse.id}/edit`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (warehouse: Warehouse) => {
|
||||||
|
if (!confirm(`Esta seguro de eliminar el almacen "${warehouse.name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeletingId(warehouse.id);
|
||||||
|
try {
|
||||||
|
await deleteWarehouse(warehouse.id);
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
}, [deleteWarehouse]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
navigate('/warehouses/new');
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
<Button onClick={() => refresh()} className="mt-4">
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Almacenes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Gestiona los almacenes y sus ubicaciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="mr-2 h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nuevo Almacen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{warehouses.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Activos</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{warehouses.filter((w) => w.isActive).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Inactivos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-400">
|
||||||
|
{warehouses.filter((w) => !w.isActive).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Por Defecto</p>
|
||||||
|
<p className="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{warehouses.filter((w) => w.isDefault).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<svg className="h-8 w-8 animate-spin text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warehouses Grid */}
|
||||||
|
{!isLoading && warehouses.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 py-12 dark:border-gray-700">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-12 w-12 text-gray-400">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
No hay almacenes
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Comienza creando tu primer almacen
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleCreate} className="mt-4">
|
||||||
|
Crear Almacen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{warehouses.map((warehouse) => (
|
||||||
|
<WarehouseCard
|
||||||
|
key={warehouse.id}
|
||||||
|
warehouse={warehouse}
|
||||||
|
onClick={() => handleWarehouseClick(warehouse)}
|
||||||
|
onEdit={() => handleEdit(warehouse)}
|
||||||
|
onDelete={deletingId === warehouse.id ? undefined : () => handleDelete(warehouse)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/features/warehouses/pages/ZonesPage.tsx
Normal file
195
src/features/warehouses/pages/ZonesPage.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { useActiveWarehouses } from '../hooks';
|
||||||
|
import { ZoneCard } from '../components';
|
||||||
|
import type { WarehouseZone, ZoneType } from '../types';
|
||||||
|
|
||||||
|
// Note: This page is prepared for when zones API is implemented in the backend.
|
||||||
|
// Currently, zones are managed through locations with locationType='zone'.
|
||||||
|
|
||||||
|
export function ZonesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { warehouses, isLoading: loadingWarehouses } = useActiveWarehouses();
|
||||||
|
const [selectedWarehouseId, setSelectedWarehouseId] = useState<string>('');
|
||||||
|
const [selectedType, setSelectedType] = useState<ZoneType | ''>('');
|
||||||
|
|
||||||
|
// Placeholder zones - in production, this would come from a useZones hook
|
||||||
|
const zones: WarehouseZone[] = [];
|
||||||
|
const isLoading = loadingWarehouses;
|
||||||
|
|
||||||
|
const filteredZones = zones.filter((zone) => {
|
||||||
|
if (selectedWarehouseId && zone.warehouseId !== selectedWarehouseId) return false;
|
||||||
|
if (selectedType && zone.zoneType !== selectedType) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleZoneClick = useCallback((zone: WarehouseZone) => {
|
||||||
|
navigate(`/warehouses/${zone.warehouseId}/zones/${zone.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleZoneEdit = useCallback((zone: WarehouseZone) => {
|
||||||
|
navigate(`/warehouses/${zone.warehouseId}/zones/${zone.id}/edit`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(() => {
|
||||||
|
if (selectedWarehouseId) {
|
||||||
|
navigate(`/warehouses/${selectedWarehouseId}/zones/new`);
|
||||||
|
} else if (warehouses.length > 0 && warehouses[0]) {
|
||||||
|
navigate(`/warehouses/${warehouses[0].id}/zones/new`);
|
||||||
|
}
|
||||||
|
}, [navigate, selectedWarehouseId, warehouses]);
|
||||||
|
|
||||||
|
const zoneTypes: { value: ZoneType | ''; label: string; color: string }[] = [
|
||||||
|
{ value: '', label: 'Todos los tipos', color: 'bg-gray-100' },
|
||||||
|
{ value: 'storage', label: 'Almacenamiento', color: 'bg-blue-100' },
|
||||||
|
{ value: 'picking', label: 'Picking', color: 'bg-green-100' },
|
||||||
|
{ value: 'packing', label: 'Empaque', color: 'bg-yellow-100' },
|
||||||
|
{ value: 'shipping', label: 'Envio', color: 'bg-purple-100' },
|
||||||
|
{ value: 'receiving', label: 'Recepcion', color: 'bg-cyan-100' },
|
||||||
|
{ value: 'quarantine', label: 'Cuarentena', color: 'bg-red-100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Zonas
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Gestiona las zonas de trabajo de los almacenes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate} disabled={warehouses.length === 0}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="mr-2 h-5 w-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
Nueva Zona
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||||
|
<div className="flex">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 text-blue-600 dark:text-blue-400">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||||
|
</svg>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
Zonas de Almacen
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
Las zonas permiten organizar el almacen en areas funcionales como
|
||||||
|
almacenamiento, picking, empaque, envio, recepcion y cuarentena.
|
||||||
|
Actualmente puedes crear ubicaciones de tipo "zona" desde la seccion de ubicaciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow dark:bg-gray-800">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Almacen
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedWarehouseId}
|
||||||
|
onChange={(e) => setSelectedWarehouseId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Todos los almacenes</option>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<option key={wh.id} value={wh.id}>
|
||||||
|
{wh.code} - {wh.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Tipo de zona
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) => setSelectedType(e.target.value as ZoneType | '')}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{zoneTypes.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone Type Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{zoneTypes.slice(1).map((type) => {
|
||||||
|
const count = zones.filter((z) => z.zoneType === type.value).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={type.value}
|
||||||
|
className={`cursor-pointer rounded-lg p-4 shadow transition-shadow hover:shadow-md ${type.color} dark:bg-opacity-20`}
|
||||||
|
onClick={() => setSelectedType(type.value as ZoneType)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{type.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<svg className="h-8 w-8 animate-spin text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zones Grid */}
|
||||||
|
{!isLoading && filteredZones.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 py-12 dark:border-gray-700">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-12 w-12 text-gray-400">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||||
|
</svg>
|
||||||
|
<p className="mt-4 text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
No hay zonas configuradas
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Utiliza ubicaciones de tipo "zona" para organizar el almacen
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/warehouses/locations')}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Ir a Ubicaciones
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredZones.map((zone) => (
|
||||||
|
<ZoneCard
|
||||||
|
key={zone.id}
|
||||||
|
zone={zone}
|
||||||
|
onClick={() => handleZoneClick(zone)}
|
||||||
|
onEdit={() => handleZoneEdit(zone)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/features/warehouses/pages/index.ts
Normal file
4
src/features/warehouses/pages/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { WarehousesPage } from './WarehousesPage';
|
||||||
|
export { WarehouseDetailPage } from './WarehouseDetailPage';
|
||||||
|
export { LocationsPage } from './LocationsPage';
|
||||||
|
export { ZonesPage } from './ZonesPage';
|
||||||
254
src/features/warehouses/types/index.ts
Normal file
254
src/features/warehouses/types/index.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
// ==================== Warehouse Types ====================
|
||||||
|
|
||||||
|
export type WarehouseType = 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual';
|
||||||
|
|
||||||
|
export interface WarehouseSettings {
|
||||||
|
allowNegative?: boolean;
|
||||||
|
autoReorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Warehouse {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
branchId: string | null;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
warehouseType: WarehouseType;
|
||||||
|
// Address
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country: string;
|
||||||
|
// Contact
|
||||||
|
managerName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
// Geolocation
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
// Capacity
|
||||||
|
capacityUnits?: number | null;
|
||||||
|
capacityVolume?: number | null;
|
||||||
|
capacityWeight?: number | null;
|
||||||
|
// Settings
|
||||||
|
settings: WarehouseSettings;
|
||||||
|
// Status
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
// Metadata
|
||||||
|
createdAt: string;
|
||||||
|
createdBy?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWarehouseDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
warehouseType?: WarehouseType;
|
||||||
|
addressLine1?: string;
|
||||||
|
addressLine2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
managerName?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
capacityUnits?: number;
|
||||||
|
capacityVolume?: number;
|
||||||
|
capacityWeight?: number;
|
||||||
|
settings?: WarehouseSettings;
|
||||||
|
isActive?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWarehouseDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
warehouseType?: WarehouseType;
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country?: string;
|
||||||
|
managerName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
capacityUnits?: number | null;
|
||||||
|
capacityVolume?: number | null;
|
||||||
|
capacityWeight?: number | null;
|
||||||
|
settings?: WarehouseSettings;
|
||||||
|
isActive?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseFilters {
|
||||||
|
search?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
warehouseType?: WarehouseType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Location Types ====================
|
||||||
|
|
||||||
|
export type LocationType = 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
|
||||||
|
|
||||||
|
export interface TemperatureRange {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumidityRange {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarehouseLocation {
|
||||||
|
id: string;
|
||||||
|
warehouseId: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
barcode?: string | null;
|
||||||
|
locationType: LocationType;
|
||||||
|
hierarchyPath?: string | null;
|
||||||
|
hierarchyLevel: number;
|
||||||
|
// Coordinates
|
||||||
|
aisle?: string | null;
|
||||||
|
rack?: string | null;
|
||||||
|
shelf?: string | null;
|
||||||
|
bin?: string | null;
|
||||||
|
// Capacity
|
||||||
|
capacityUnits?: number | null;
|
||||||
|
capacityVolume?: number | null;
|
||||||
|
capacityWeight?: number | null;
|
||||||
|
// Restrictions
|
||||||
|
allowedProductTypes: string[];
|
||||||
|
temperatureRange?: TemperatureRange | null;
|
||||||
|
humidityRange?: HumidityRange | null;
|
||||||
|
// Status
|
||||||
|
isActive: boolean;
|
||||||
|
isPickable: boolean;
|
||||||
|
isReceivable: boolean;
|
||||||
|
// Metadata
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLocationDto {
|
||||||
|
warehouseId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
locationType?: LocationType;
|
||||||
|
barcode?: string;
|
||||||
|
aisle?: string;
|
||||||
|
rack?: string;
|
||||||
|
shelf?: string;
|
||||||
|
bin?: string;
|
||||||
|
capacityUnits?: number;
|
||||||
|
capacityVolume?: number;
|
||||||
|
capacityWeight?: number;
|
||||||
|
allowedProductTypes?: string[];
|
||||||
|
temperatureRange?: TemperatureRange;
|
||||||
|
humidityRange?: HumidityRange;
|
||||||
|
isActive?: boolean;
|
||||||
|
isPickable?: boolean;
|
||||||
|
isReceivable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLocationDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
locationType?: LocationType;
|
||||||
|
barcode?: string | null;
|
||||||
|
aisle?: string | null;
|
||||||
|
rack?: string | null;
|
||||||
|
shelf?: string | null;
|
||||||
|
bin?: string | null;
|
||||||
|
capacityUnits?: number | null;
|
||||||
|
capacityVolume?: number | null;
|
||||||
|
capacityWeight?: number | null;
|
||||||
|
allowedProductTypes?: string[];
|
||||||
|
temperatureRange?: TemperatureRange | null;
|
||||||
|
humidityRange?: HumidityRange | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
isPickable?: boolean;
|
||||||
|
isReceivable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationFilters {
|
||||||
|
warehouseId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
locationType?: LocationType;
|
||||||
|
isActive?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Zone Types ====================
|
||||||
|
|
||||||
|
export type ZoneType = 'storage' | 'picking' | 'packing' | 'shipping' | 'receiving' | 'quarantine';
|
||||||
|
|
||||||
|
export interface WarehouseZone {
|
||||||
|
id: string;
|
||||||
|
warehouseId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
zoneType: ZoneType;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateZoneDto {
|
||||||
|
warehouseId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
zoneType?: ZoneType;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateZoneDto {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
zoneType?: ZoneType;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Response Types ====================
|
||||||
|
|
||||||
|
export interface WarehousesResponse {
|
||||||
|
data: Warehouse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsResponse {
|
||||||
|
data: WarehouseLocation[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZonesResponse {
|
||||||
|
data: WarehouseZone[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user