erp-core-frontend-v2/src/features/warehouses/components/WarehouseLayout.tsx
Adrian Flores Cortes 158ebcb57b [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>
2026-02-04 00:16:34 -06:00

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