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>
184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|