diff --git a/src/features/products/api/categories.api.ts b/src/features/products/api/categories.api.ts new file mode 100644 index 0000000..c319829 --- /dev/null +++ b/src/features/products/api/categories.api.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + await api.delete(`${CATEGORIES_URL}/${id}`); + }, + + // Get root categories (no parent) + getRoots: async (): Promise => { + 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 => { + 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(); + 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); + }, +}; diff --git a/src/features/products/api/products.api.ts b/src/features/products/api/products.api.ts new file mode 100644 index 0000000..aff9373 --- /dev/null +++ b/src/features/products/api/products.api.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + await api.delete(`${PRODUCTS_URL}/${id}`); + }, + + // Get sellable products + getSellable: async (limit = 50, offset = 0): Promise => { + 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 => { + 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, + }; + }, +}; diff --git a/src/features/products/components/CategoryTree.tsx b/src/features/products/components/CategoryTree.tsx new file mode 100644 index 0000000..3f9d109 --- /dev/null +++ b/src/features/products/components/CategoryTree.tsx @@ -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; + 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 ( +
+
+ {/* Expand/collapse */} + + + {/* Icon */} + {isExpanded ? ( + + ) : ( + + )} + + {/* Name */} + {node.name} + + {/* Code badge */} + {node.code} + + {/* Actions */} + {showActions && menuItems.length > 0 && ( + e.stopPropagation()} + > + + + } + /> + )} +
+ + {/* Children */} + {hasChildren && isExpanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function CategoryTree({ + selectedId, + onSelect, + onAdd, + onEdit, + onDelete, + showActions = true, + className, +}: CategoryTreeProps) { + const { tree, isLoading, error } = useCategoryTree(); + const [expandedIds, setExpandedIds] = useState>(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(); + 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 ( +
+
Cargando categorias...
+
+ ); + } + + if (error) { + return ( +
+
Error al cargar categorias
+
+ ); + } + + if (tree.length === 0) { + return ( +
+ +

No hay categorias

+ {onAdd && ( + + )} +
+ ); + } + + return ( +
+ {/* Header */} +
+ Categorias +
+ + + {onAdd && ( + + )} +
+
+ + {/* Tree */} +
+ {tree.map((node) => ( + + ))} +
+
+ ); +} diff --git a/src/features/products/components/PricingTable.tsx b/src/features/products/components/PricingTable.tsx new file mode 100644 index 0000000..d56545b --- /dev/null +++ b/src/features/products/components/PricingTable.tsx @@ -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 = { + 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(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[] = [ + { + key: 'priceType', + header: 'Tipo', + render: (row) => { + const config = priceTypeConfig[row.priceType]; + return {config.label}; + }, + }, + { + key: 'priceListName', + header: 'Lista de Precios', + render: (row) => row.priceListName || '-', + }, + { + key: 'price', + header: 'Precio', + align: 'right', + render: (row) => ( + {formatPrice(row.price, row.currency)} + ), + }, + { + 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 ( + = 0 ? 'text-green-600' : 'text-red-600')}> + {margin.toFixed(1)}% + + ); + }, + }, + { + key: 'validFrom', + header: 'Vigencia', + render: (row) => ( +
+
+ + {formatDate(row.validFrom)} +
+ {row.validTo && ( +
+ al {formatDate(row.validTo)} +
+ )} +
+ ), + }, + { + key: 'isActive', + header: 'Estado', + render: (row) => ( + + {row.isActive ? 'Activo' : 'Inactivo'} + + ), + }, + { + key: 'actions', + header: '', + align: 'right', + render: (row) => ( +
+ {onEditPrice && ( + + )} + +
+ ), + }, + ]; + + return ( + + + + + Precios del Producto + + {onAddPrice && ( + + )} + + + {/* Base price summary */} +
+
+ Precio base del producto: + {formatPrice(productPrice, currency)} +
+ {productCost > 0 && ( +
+ Costo: + {formatPrice(productCost, currency)} +
+ )} +
+ + {/* Prices table */} + {error ? ( +
+ Error al cargar precios +
+ ) : prices.length === 0 && !isLoading ? ( +
+ +

No hay precios configurados

+ {onAddPrice && ( + + )} +
+ ) : ( + + )} +
+ + {/* Delete confirmation modal */} + { + 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} + /> +
+ ); +} diff --git a/src/features/products/components/ProductCard.tsx b/src/features/products/components/ProductCard.tsx new file mode 100644 index 0000000..0b20092 --- /dev/null +++ b/src/features/products/components/ProductCard.tsx @@ -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 = { + 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 ( + onClick?.(product)} + > + +
+ {/* Image */} +
+ {hasImage ? ( + {product.name} + ) : ( +
+ +
+ )} +
+ + {/* Content */} +
+
+
+

{product.name}

+

{product.sku}

+
+ + {menuItems.length > 0 && ( + e.stopPropagation()} + > + + + } + /> + )} +
+ +
+ + {typeConfig.label} + + + {product.category && ( + + + {product.category.name} + + )} + + {!product.isActive && ( + Inactivo + )} +
+ +
+
+ + {formatPrice(product.price)} + {product.cost > 0 && ( + / Costo: {formatPrice(product.cost)} + )} +
+
+
+
+
+
+ ); +} diff --git a/src/features/products/components/ProductForm.tsx b/src/features/products/components/ProductForm.tsx new file mode 100644 index 0000000..632acb1 --- /dev/null +++ b/src/features/products/components/ProductForm.tsx @@ -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; + +const productTypeLabels: Record = { + product: 'Producto', + service: 'Servicio', + consumable: 'Consumible', + kit: 'Kit', +}; + +export interface ProductFormProps { + product?: Product | null; + onSubmit: (data: CreateProductDto | UpdateProductDto) => Promise; + 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({ + 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 ( +
+ {/* Identificacion */} + + + Identificacion del Producto + + +
+ + + + + + + +
+ + + + + + + + + + +