[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:
Adrian Flores Cortes 2026-02-04 00:16:34 -06:00
parent 29c76fcbd6
commit 158ebcb57b
37 changed files with 5892 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,3 @@
export { ProductsPage } from './ProductsPage';
export { ProductDetailPage } from './ProductDetailPage';
export { CategoriesPage } from './CategoriesPage';

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

View File

@ -0,0 +1 @@
export { warehousesApi, locationsApi } from './warehouses.api';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,4 @@
export { WarehousesPage } from './WarehousesPage';
export { WarehouseDetailPage } from './WarehouseDetailPage';
export { LocationsPage } from './LocationsPage';
export { ZonesPage } from './ZonesPage';

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