From b9068be3d947cf91addc672b77a1c7db1cfea960 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 01:00:46 -0600 Subject: [PATCH] [TASK-2026-02-03-INTEGRACION] feat: Frontend integration improvements ## Products Feature (24 files) - Convert hooks from @tanstack/react-query to useState/useEffect - Fix page components to use new hook structure - Add missing API files and components ## Warehouses Feature (20 files) - Complete feature using correct hook pattern ## Partners Hooks (3 files) - Convert usePartnerAddresses to useState/useEffect - Convert usePartnerContacts to useState/useEffect - Convert usePartnerBankAccounts to useState/useEffect ## Companies Hooks (2 files) - Convert useCompanyBranches to useState/useEffect - Convert useCompanySettings to useState/useEffect Co-Authored-By: Claude Opus 4.5 --- .../companies/hooks/useCompanyBranches.ts | 49 +- .../companies/hooks/useCompanySettings.ts | 66 ++- .../partners/hooks/usePartnerAddresses.ts | 151 +++--- .../partners/hooks/usePartnerBankAccounts.ts | 177 ++++--- .../partners/hooks/usePartnerContacts.ts | 151 +++--- src/features/products/api/categories.api.ts | 81 ++- src/features/products/api/index.ts | 3 + src/features/products/api/pricing.api.ts | 64 +++ src/features/products/api/products.api.ts | 159 +++--- .../products/components/AttributeEditor.tsx | 262 ++++++++++ .../products/components/PricingTable.tsx | 16 +- .../products/components/ProductTable.tsx | 261 ++++++++++ src/features/products/components/index.ts | 6 + src/features/products/hooks/index.ts | 12 +- src/features/products/hooks/useCategories.ts | 273 +++++++--- .../products/hooks/useProductPricing.ts | 208 ++++---- .../products/hooks/useProductVariants.ts | 121 +++++ src/features/products/hooks/useProducts.ts | 265 ++++++---- src/features/products/index.ts | 1 + .../products/pages/CategoriesPage.tsx | 50 +- src/features/products/pages/PricingPage.tsx | 472 ++++++++++++++++++ .../products/pages/ProductDetailPage.tsx | 35 +- src/features/products/pages/ProductsPage.tsx | 26 +- src/features/products/pages/index.ts | 1 + src/features/products/types/index.ts | 173 ++++++- src/features/warehouses/api/index.ts | 2 +- src/features/warehouses/api/warehouses.api.ts | 63 +++ src/features/warehouses/hooks/index.ts | 7 + src/features/warehouses/hooks/useZones.ts | 169 +++++++ src/features/warehouses/index.ts | 6 +- 30 files changed, 2630 insertions(+), 700 deletions(-) create mode 100644 src/features/products/api/index.ts create mode 100644 src/features/products/api/pricing.api.ts create mode 100644 src/features/products/components/AttributeEditor.tsx create mode 100644 src/features/products/components/ProductTable.tsx create mode 100644 src/features/products/hooks/useProductVariants.ts create mode 100644 src/features/products/pages/PricingPage.tsx create mode 100644 src/features/warehouses/hooks/useZones.ts diff --git a/src/features/companies/hooks/useCompanyBranches.ts b/src/features/companies/hooks/useCompanyBranches.ts index a0ec311..dd11217 100644 --- a/src/features/companies/hooks/useCompanyBranches.ts +++ b/src/features/companies/hooks/useCompanyBranches.ts @@ -1,20 +1,41 @@ -import { useQuery } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { companiesApi } from '../api'; -import type { BranchesResponse } from '../types'; - -const QUERY_KEY = 'companies'; +import type { Branch } from '../types'; // ==================== Company Branches Hook ==================== export function useCompanyBranches(companyId: string | null | undefined) { - return useQuery({ - queryKey: [QUERY_KEY, 'branches', companyId], - queryFn: () => companiesApi.getBranches(companyId as string), - enabled: !!companyId, - staleTime: 1000 * 60 * 5, // 5 minutes - select: (response: BranchesResponse) => ({ - branches: response.data, - total: response.total, - }), - }); + const [branches, setBranches] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchBranches = useCallback(async () => { + if (!companyId) return; + setIsLoading(true); + setError(null); + try { + const response = await companiesApi.getBranches(companyId); + setBranches(response.data || []); + setTotal(response.total || 0); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar sucursales'); + } finally { + setIsLoading(false); + } + }, [companyId]); + + useEffect(() => { + if (companyId) { + fetchBranches(); + } + }, [fetchBranches, companyId]); + + return { + branches, + total, + isLoading, + error, + refresh: fetchBranches, + }; } diff --git a/src/features/companies/hooks/useCompanySettings.ts b/src/features/companies/hooks/useCompanySettings.ts index 431612f..53b8420 100644 --- a/src/features/companies/hooks/useCompanySettings.ts +++ b/src/features/companies/hooks/useCompanySettings.ts @@ -1,38 +1,54 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { companiesApi } from '../api'; import type { CompanySettings } from '../types'; -const QUERY_KEY = 'companies'; - // ==================== Company Settings Hook ==================== export function useCompanySettings(companyId: string | null | undefined) { - return useQuery({ - queryKey: [QUERY_KEY, 'settings', companyId], - queryFn: () => companiesApi.getSettings(companyId as string), - enabled: !!companyId, - staleTime: 1000 * 60 * 5, // 5 minutes - }); -} + const [settings, setSettings] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Company Settings Mutations Hook ==================== + const fetchSettings = useCallback(async () => { + if (!companyId) return; + setIsLoading(true); + setError(null); + try { + const data = await companiesApi.getSettings(companyId); + setSettings(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar configuración'); + } finally { + setIsLoading(false); + } + }, [companyId]); -export function useCompanySettingsMutations() { - const queryClient = useQueryClient(); + useEffect(() => { + if (companyId) { + fetchSettings(); + } + }, [fetchSettings, companyId]); - const updateMutation = useMutation({ - mutationFn: ({ companyId, settings }: { companyId: string; settings: CompanySettings }) => - companiesApi.updateSettings(companyId, settings), - onSuccess: (_: unknown, variables: { companyId: string; settings: CompanySettings }) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'settings', variables.companyId] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.companyId] }); - }, - }); + const updateSettings = async (newSettings: CompanySettings) => { + if (!companyId) throw new Error('Company ID required'); + setIsLoading(true); + try { + const updated = await companiesApi.updateSettings(companyId, newSettings); + setSettings(updated); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar configuración'); + throw err; + } finally { + setIsLoading(false); + } + }; return { - updateSettings: updateMutation.mutateAsync, - isUpdating: updateMutation.isPending, - updateError: updateMutation.error, + settings, + isLoading, + error, + refresh: fetchSettings, + updateSettings, }; } diff --git a/src/features/partners/hooks/usePartnerAddresses.ts b/src/features/partners/hooks/usePartnerAddresses.ts index 2012291..9fb1c3e 100644 --- a/src/features/partners/hooks/usePartnerAddresses.ts +++ b/src/features/partners/hooks/usePartnerAddresses.ts @@ -1,88 +1,105 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { addressesApi } from '../api/addresses.api'; import type { + PartnerAddress, CreatePartnerAddressDto, UpdatePartnerAddressDto, } from '../types'; -const QUERY_KEY = 'partner-addresses'; - // ==================== Addresses List Hook ==================== export function usePartnerAddresses(partnerId: string | null | undefined) { - return useQuery({ - queryKey: [QUERY_KEY, partnerId], - queryFn: () => addressesApi.getByPartnerId(partnerId as string), - enabled: !!partnerId, - staleTime: 1000 * 60 * 5, // 5 minutes - }); -} + const [addresses, setAddresses] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Address Mutations Hook ==================== + const fetchAddresses = useCallback(async () => { + if (!partnerId) return; + setIsLoading(true); + setError(null); + try { + const data = await addressesApi.getByPartnerId(partnerId); + setAddresses(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar direcciones'); + } finally { + setIsLoading(false); + } + }, [partnerId]); -export function usePartnerAddressMutations(partnerId: string) { - const queryClient = useQueryClient(); + useEffect(() => { + if (partnerId) { + fetchAddresses(); + } + }, [fetchAddresses, partnerId]); - const createMutation = useMutation({ - mutationFn: (data: CreatePartnerAddressDto) => addressesApi.create(partnerId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ addressId, data }: { addressId: string; data: UpdatePartnerAddressDto }) => - addressesApi.update(partnerId, addressId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (addressId: string) => addressesApi.delete(partnerId, addressId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const setDefaultMutation = useMutation({ - mutationFn: (addressId: string) => addressesApi.setDefault(partnerId, addressId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - return { - create: createMutation.mutateAsync, - update: updateMutation.mutateAsync, - delete: deleteMutation.mutateAsync, - setDefault: setDefaultMutation.mutateAsync, - isCreating: createMutation.isPending, - isUpdating: updateMutation.isPending, - isDeleting: deleteMutation.isPending, - isSettingDefault: setDefaultMutation.isPending, - createError: createMutation.error, - updateError: updateMutation.error, - deleteError: deleteMutation.error, + const createAddress = async (data: CreatePartnerAddressDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const newAddress = await addressesApi.create(partnerId, data); + await fetchAddresses(); + return newAddress; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear dirección'); + throw err; + } finally { + setIsLoading(false); + } }; -} -// ==================== Combined Hook ==================== + const updateAddress = async (addressId: string, data: UpdatePartnerAddressDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const updated = await addressesApi.update(partnerId, addressId, data); + await fetchAddresses(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar dirección'); + throw err; + } finally { + setIsLoading(false); + } + }; -export function usePartnerAddressesWithMutations(partnerId: string | null | undefined) { - const { data, isLoading, error, refetch } = usePartnerAddresses(partnerId); - const mutations = usePartnerAddressMutations(partnerId || ''); + const deleteAddress = async (addressId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + await addressesApi.delete(partnerId, addressId); + await fetchAddresses(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar dirección'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const setDefaultAddress = async (addressId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const result = await addressesApi.setDefault(partnerId, addressId); + await fetchAddresses(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al establecer dirección por defecto'); + throw err; + } finally { + setIsLoading(false); + } + }; return { - addresses: data || [], + addresses, isLoading, error, - refetch, - ...mutations, - // Disable mutations if no partnerId - create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, - update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, - delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); }, - setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); }, + refresh: fetchAddresses, + createAddress, + updateAddress, + deleteAddress, + setDefaultAddress, }; } diff --git a/src/features/partners/hooks/usePartnerBankAccounts.ts b/src/features/partners/hooks/usePartnerBankAccounts.ts index bbd4660..25b4feb 100644 --- a/src/features/partners/hooks/usePartnerBankAccounts.ts +++ b/src/features/partners/hooks/usePartnerBankAccounts.ts @@ -1,98 +1,121 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { bankAccountsApi } from '../api/bankAccounts.api'; import type { + PartnerBankAccount, CreatePartnerBankAccountDto, UpdatePartnerBankAccountDto, } from '../types'; -const QUERY_KEY = 'partner-bank-accounts'; - // ==================== Bank Accounts List Hook ==================== export function usePartnerBankAccounts(partnerId: string | null | undefined) { - return useQuery({ - queryKey: [QUERY_KEY, partnerId], - queryFn: () => bankAccountsApi.getByPartnerId(partnerId as string), - enabled: !!partnerId, - staleTime: 1000 * 60 * 5, // 5 minutes - }); -} + const [bankAccounts, setBankAccounts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Bank Account Mutations Hook ==================== + const fetchBankAccounts = useCallback(async () => { + if (!partnerId) return; + setIsLoading(true); + setError(null); + try { + const data = await bankAccountsApi.getByPartnerId(partnerId); + setBankAccounts(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar cuentas bancarias'); + } finally { + setIsLoading(false); + } + }, [partnerId]); -export function usePartnerBankAccountMutations(partnerId: string) { - const queryClient = useQueryClient(); + useEffect(() => { + if (partnerId) { + fetchBankAccounts(); + } + }, [fetchBankAccounts, partnerId]); - const createMutation = useMutation({ - mutationFn: (data: CreatePartnerBankAccountDto) => bankAccountsApi.create(partnerId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ accountId, data }: { accountId: string; data: UpdatePartnerBankAccountDto }) => - bankAccountsApi.update(partnerId, accountId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (accountId: string) => bankAccountsApi.delete(partnerId, accountId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const verifyMutation = useMutation({ - mutationFn: (accountId: string) => bankAccountsApi.verify(partnerId, accountId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const setDefaultMutation = useMutation({ - mutationFn: (accountId: string) => bankAccountsApi.setDefault(partnerId, accountId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - return { - create: createMutation.mutateAsync, - update: updateMutation.mutateAsync, - delete: deleteMutation.mutateAsync, - verify: verifyMutation.mutateAsync, - setDefault: setDefaultMutation.mutateAsync, - isCreating: createMutation.isPending, - isUpdating: updateMutation.isPending, - isDeleting: deleteMutation.isPending, - isVerifying: verifyMutation.isPending, - isSettingDefault: setDefaultMutation.isPending, - createError: createMutation.error, - updateError: updateMutation.error, - deleteError: deleteMutation.error, + const createBankAccount = async (data: CreatePartnerBankAccountDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const newAccount = await bankAccountsApi.create(partnerId, data); + await fetchBankAccounts(); + return newAccount; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear cuenta bancaria'); + throw err; + } finally { + setIsLoading(false); + } }; -} -// ==================== Combined Hook ==================== + const updateBankAccount = async (accountId: string, data: UpdatePartnerBankAccountDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const updated = await bankAccountsApi.update(partnerId, accountId, data); + await fetchBankAccounts(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar cuenta bancaria'); + throw err; + } finally { + setIsLoading(false); + } + }; -export function usePartnerBankAccountsWithMutations(partnerId: string | null | undefined) { - const { data, isLoading, error, refetch } = usePartnerBankAccounts(partnerId); - const mutations = usePartnerBankAccountMutations(partnerId || ''); + const deleteBankAccount = async (accountId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + await bankAccountsApi.delete(partnerId, accountId); + await fetchBankAccounts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar cuenta bancaria'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const verifyBankAccount = async (accountId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const result = await bankAccountsApi.verify(partnerId, accountId); + await fetchBankAccounts(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al verificar cuenta bancaria'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const setDefaultBankAccount = async (accountId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const result = await bankAccountsApi.setDefault(partnerId, accountId); + await fetchBankAccounts(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al establecer cuenta por defecto'); + throw err; + } finally { + setIsLoading(false); + } + }; return { - bankAccounts: data || [], + bankAccounts, isLoading, error, - refetch, - ...mutations, - // Disable mutations if no partnerId - create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, - update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, - delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); }, - verify: partnerId ? mutations.verify : async () => { throw new Error('Partner ID required'); }, - setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); }, + refresh: fetchBankAccounts, + createBankAccount, + updateBankAccount, + deleteBankAccount, + verifyBankAccount, + setDefaultBankAccount, }; } diff --git a/src/features/partners/hooks/usePartnerContacts.ts b/src/features/partners/hooks/usePartnerContacts.ts index 8eb1f8c..12b50bc 100644 --- a/src/features/partners/hooks/usePartnerContacts.ts +++ b/src/features/partners/hooks/usePartnerContacts.ts @@ -1,88 +1,105 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { contactsApi } from '../api/contacts.api'; import type { + PartnerContact, CreatePartnerContactDto, UpdatePartnerContactDto, } from '../types'; -const QUERY_KEY = 'partner-contacts'; - // ==================== Contacts List Hook ==================== export function usePartnerContacts(partnerId: string | null | undefined) { - return useQuery({ - queryKey: [QUERY_KEY, partnerId], - queryFn: () => contactsApi.getByPartnerId(partnerId as string), - enabled: !!partnerId, - staleTime: 1000 * 60 * 5, // 5 minutes - }); -} + const [contacts, setContacts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Contact Mutations Hook ==================== + const fetchContacts = useCallback(async () => { + if (!partnerId) return; + setIsLoading(true); + setError(null); + try { + const data = await contactsApi.getByPartnerId(partnerId); + setContacts(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar contactos'); + } finally { + setIsLoading(false); + } + }, [partnerId]); -export function usePartnerContactMutations(partnerId: string) { - const queryClient = useQueryClient(); + useEffect(() => { + if (partnerId) { + fetchContacts(); + } + }, [fetchContacts, partnerId]); - const createMutation = useMutation({ - mutationFn: (data: CreatePartnerContactDto) => contactsApi.create(partnerId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ contactId, data }: { contactId: string; data: UpdatePartnerContactDto }) => - contactsApi.update(partnerId, contactId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (contactId: string) => contactsApi.delete(partnerId, contactId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - const setPrimaryMutation = useMutation({ - mutationFn: (contactId: string) => contactsApi.setPrimary(partnerId, contactId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); - }, - }); - - return { - create: createMutation.mutateAsync, - update: updateMutation.mutateAsync, - delete: deleteMutation.mutateAsync, - setPrimary: setPrimaryMutation.mutateAsync, - isCreating: createMutation.isPending, - isUpdating: updateMutation.isPending, - isDeleting: deleteMutation.isPending, - isSettingPrimary: setPrimaryMutation.isPending, - createError: createMutation.error, - updateError: updateMutation.error, - deleteError: deleteMutation.error, + const createContact = async (data: CreatePartnerContactDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const newContact = await contactsApi.create(partnerId, data); + await fetchContacts(); + return newContact; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear contacto'); + throw err; + } finally { + setIsLoading(false); + } }; -} -// ==================== Combined Hook ==================== + const updateContact = async (contactId: string, data: UpdatePartnerContactDto) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const updated = await contactsApi.update(partnerId, contactId, data); + await fetchContacts(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar contacto'); + throw err; + } finally { + setIsLoading(false); + } + }; -export function usePartnerContactsWithMutations(partnerId: string | null | undefined) { - const { data, isLoading, error, refetch } = usePartnerContacts(partnerId); - const mutations = usePartnerContactMutations(partnerId || ''); + const deleteContact = async (contactId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + await contactsApi.delete(partnerId, contactId); + await fetchContacts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar contacto'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const setPrimaryContact = async (contactId: string) => { + if (!partnerId) throw new Error('Partner ID required'); + setIsLoading(true); + try { + const result = await contactsApi.setPrimary(partnerId, contactId); + await fetchContacts(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al establecer contacto principal'); + throw err; + } finally { + setIsLoading(false); + } + }; return { - contacts: data || [], + contacts, isLoading, error, - refetch, - ...mutations, - // Disable mutations if no partnerId - create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, - update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, - delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); }, - setPrimary: partnerId ? mutations.setPrimary : async () => { throw new Error('Partner ID required'); }, + refresh: fetchContacts, + createContact, + updateContact, + deleteContact, + setPrimaryContact, }; } diff --git a/src/features/products/api/categories.api.ts b/src/features/products/api/categories.api.ts index c319829..9373cc9 100644 --- a/src/features/products/api/categories.api.ts +++ b/src/features/products/api/categories.api.ts @@ -3,7 +3,7 @@ import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, - CategorySearchParams, + CategoryFilters, CategoriesResponse, CategoryTreeNode, } from '../types'; @@ -11,86 +11,68 @@ import type { 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)); + getAll: async (filters?: CategoryFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.parentId) params.append('parentId', filters.parentId); + if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive)); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); - const queryString = searchParams.toString(); + const queryString = params.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); + const response = await api.get(url); return { data: response.data.data || [], - total: response.data.total || 0, - limit: response.data.limit || 50, - offset: response.data.offset || 0, + meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 }, }; }, - // 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; + const response = await api.get(`${CATEGORIES_URL}/${id}`); + return response.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; + const response = await api.post(CATEGORIES_URL, data); + return response.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; + const response = await api.patch(`${CATEGORIES_URL}/${id}`, data); + return response.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=` - ); + const response = await api.get(`${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}` - ); + const response = await api.get(`${CATEGORIES_URL}?parentId=${parentId}`); return response.data.data || []; }, - // Build category tree from flat list + getTree: async (): Promise => { + const response = await categoriesApi.getAll({ limit: 1000 }); + return categoriesApi.buildTree(response.data); + }, + 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; @@ -103,7 +85,6 @@ export const categoriesApi = { } }); - // Sort by sortOrder const sortNodes = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => { nodes.sort((a, b) => a.sortOrder - b.sortOrder); nodes.forEach((node) => { @@ -116,4 +97,14 @@ export const categoriesApi = { return sortNodes(roots); }, + + activate: async (id: string): Promise => { + const response = await api.patch(`${CATEGORIES_URL}/${id}`, { isActive: true }); + return response.data; + }, + + deactivate: async (id: string): Promise => { + const response = await api.patch(`${CATEGORIES_URL}/${id}`, { isActive: false }); + return response.data; + }, }; diff --git a/src/features/products/api/index.ts b/src/features/products/api/index.ts new file mode 100644 index 0000000..23ed6ba --- /dev/null +++ b/src/features/products/api/index.ts @@ -0,0 +1,3 @@ +export { productsApi } from './products.api'; +export { categoriesApi } from './categories.api'; +export { pricingApi } from './pricing.api'; diff --git a/src/features/products/api/pricing.api.ts b/src/features/products/api/pricing.api.ts new file mode 100644 index 0000000..fe74a14 --- /dev/null +++ b/src/features/products/api/pricing.api.ts @@ -0,0 +1,64 @@ +import { api } from '@services/api/axios-instance'; +import type { + ProductPrice, + CreatePriceDto, + UpdatePriceDto, + PricesResponse, +} from '../types'; + +const PRODUCTS_URL = '/api/v1/products'; + +export const pricingApi = { + getByProductId: async (productId: string): Promise => { + const response = await api.get(`${PRODUCTS_URL}/${productId}/prices`); + return { + data: response.data.data || [], + total: response.data.total || 0, + }; + }, + + create: async (productId: string, data: CreatePriceDto): Promise => { + const response = await api.post(`${PRODUCTS_URL}/${productId}/prices`, data); + return response.data; + }, + + update: async (productId: string, priceId: string, data: UpdatePriceDto): Promise => { + const response = await api.patch(`${PRODUCTS_URL}/${productId}/prices/${priceId}`, data); + return response.data; + }, + + delete: async (productId: string, priceId: string): Promise => { + await api.delete(`${PRODUCTS_URL}/${productId}/prices/${priceId}`); + }, + + activate: async (productId: string, priceId: string): Promise => { + const response = await api.patch( + `${PRODUCTS_URL}/${productId}/prices/${priceId}`, + { isActive: true } + ); + return response.data; + }, + + deactivate: async (productId: string, priceId: string): Promise => { + const response = await api.patch( + `${PRODUCTS_URL}/${productId}/prices/${priceId}`, + { isActive: false } + ); + return response.data; + }, + + getActivePrice: async (productId: string, priceType: string = 'standard'): Promise => { + const response = await pricingApi.getByProductId(productId); + const now = new Date().toISOString(); + + const activePrice = response.data.find( + (price) => + price.priceType === priceType && + price.isActive && + price.validFrom <= now && + (!price.validTo || price.validTo >= now) + ); + + return activePrice || null; + }, +}; diff --git a/src/features/products/api/products.api.ts b/src/features/products/api/products.api.ts index aff9373..16f52ed 100644 --- a/src/features/products/api/products.api.ts +++ b/src/features/products/api/products.api.ts @@ -3,110 +3,129 @@ import type { Product, CreateProductDto, UpdateProductDto, - ProductSearchParams, + ProductFilters, ProductsResponse, + ProductVariant, + CreateVariantDto, + UpdateVariantDto, + VariantsResponse, + ProductAttribute, + AttributesResponse, } 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)); + // ==================== Products CRUD ==================== - const queryString = searchParams.toString(); + getAll: async (filters?: ProductFilters): Promise => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.categoryId) params.append('categoryId', filters.categoryId); + if (filters?.productType) params.append('productType', filters.productType); + if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive)); + if (filters?.isSellable !== undefined) params.append('isSellable', String(filters.isSellable)); + if (filters?.isPurchasable !== undefined) params.append('isPurchasable', String(filters.isPurchasable)); + if (filters?.minPrice !== undefined) params.append('minPrice', String(filters.minPrice)); + if (filters?.maxPrice !== undefined) params.append('maxPrice', String(filters.maxPrice)); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.limit) params.append('limit', String(filters.limit)); + if (filters?.sortBy) params.append('sortBy', filters.sortBy); + if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder); + + const queryString = params.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); + const response = await api.get(url); return { data: response.data.data || [], - total: response.data.total || 0, - limit: response.data.limit || 50, - offset: response.data.offset || 0, + meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 }, }; }, - // 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; + const response = await api.get(`${PRODUCTS_URL}/${id}`); + return response.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; + const response = await api.get(`${PRODUCTS_URL}/sku/${sku}`); + return response.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; + const response = await api.get(`${PRODUCTS_URL}/barcode/${barcode}`); + return response.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; + const response = await api.post(PRODUCTS_URL, data); + return response.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; + const response = await api.patch(`${PRODUCTS_URL}/${id}`, data); + return response.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, - }; + // ==================== Product Variants ==================== + + getVariants: async (productId: string): Promise => { + const response = await api.get(`${PRODUCTS_URL}/${productId}/variants`); + return response.data; }, - // 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, - }; + createVariant: async (productId: string, data: CreateVariantDto): Promise => { + const response = await api.post(`${PRODUCTS_URL}/${productId}/variants`, data); + return response.data; + }, + + updateVariant: async (productId: string, variantId: string, data: UpdateVariantDto): Promise => { + const response = await api.patch(`${PRODUCTS_URL}/${productId}/variants/${variantId}`, data); + return response.data; + }, + + deleteVariant: async (productId: string, variantId: string): Promise => { + await api.delete(`${PRODUCTS_URL}/${productId}/variants/${variantId}`); + }, + + // ==================== Product Attributes ==================== + + getAttributes: async (productId: string): Promise => { + const response = await api.get(`${PRODUCTS_URL}/${productId}/attributes`); + return response.data; + }, + + addAttribute: async (productId: string, attributeId: string): Promise => { + const response = await api.post(`${PRODUCTS_URL}/${productId}/attributes`, { attributeId }); + return response.data; + }, + + removeAttribute: async (productId: string, attributeId: string): Promise => { + await api.delete(`${PRODUCTS_URL}/${productId}/attributes/${attributeId}`); + }, + + // ==================== Utility Methods ==================== + + activate: async (id: string): Promise => { + const response = await api.patch(`${PRODUCTS_URL}/${id}`, { isActive: true }); + return response.data; + }, + + deactivate: async (id: string): Promise => { + const response = await api.patch(`${PRODUCTS_URL}/${id}`, { isActive: false }); + return response.data; + }, + + getSellable: async (filters?: ProductFilters): Promise => { + return productsApi.getAll({ ...filters, isSellable: true }); + }, + + getPurchasable: async (filters?: ProductFilters): Promise => { + return productsApi.getAll({ ...filters, isPurchasable: true }); }, }; diff --git a/src/features/products/components/AttributeEditor.tsx b/src/features/products/components/AttributeEditor.tsx new file mode 100644 index 0000000..5087460 --- /dev/null +++ b/src/features/products/components/AttributeEditor.tsx @@ -0,0 +1,262 @@ +import { useState } from 'react'; +import { Button } from '@components/atoms/Button'; +import { Badge } from '@components/atoms/Badge'; +import { Modal, ModalContent, ModalFooter } from '@components/organisms/Modal'; +import { Plus, X, Palette, Image as ImageIcon } from 'lucide-react'; +import type { ProductAttribute, ProductAttributeValue, AttributeDisplayType } from '../types'; + +export interface AttributeEditorProps { + attributes: ProductAttribute[]; + onAddAttribute?: () => void; + onRemoveAttribute?: (attributeId: string) => void; + onAddValue?: (attributeId: string) => void; + onRemoveValue?: (attributeId: string, valueId: string) => void; + isLoading?: boolean; + editable?: boolean; +} + +const displayTypeIcons: Record = { + radio: O, + select: V, + color: , + pills: [], +}; + +const displayTypeLabels: Record = { + radio: 'Radio buttons', + select: 'Dropdown', + color: 'Selector de color', + pills: 'Pastillas', +}; + +export function AttributeEditor({ + attributes, + onAddAttribute, + onRemoveAttribute, + onAddValue, + onRemoveValue, + isLoading = false, + editable = true, +}: AttributeEditorProps) { + const [selectedAttribute, setSelectedAttribute] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleViewAttribute = (attribute: ProductAttribute) => { + setSelectedAttribute(attribute); + setIsModalOpen(true); + }; + + const renderColorSwatch = (color: string | null) => { + if (!color) return null; + return ( + + ); + }; + + const renderValue = (attribute: ProductAttribute, value: ProductAttributeValue) => { + return ( +
+ {attribute.displayType === 'color' && renderColorSwatch(value.htmlColor)} + {value.imageUrl && } + {value.name} + {value.code && ( + ({value.code}) + )} + {editable && onRemoveValue && ( + + )} +
+ ); + }; + + if (attributes.length === 0) { + return ( +
+
+ +
+

Sin atributos

+

+ Agrega atributos como color, talla o material para crear variantes. +

+ {editable && onAddAttribute && ( + + )} +
+ ); + } + + return ( +
+ {/* Attributes list */} +
+ {attributes.map((attribute) => ( +
+
+
+
+ {displayTypeIcons[attribute.displayType]} +
+
+

{attribute.name}

+
+ {attribute.code} + - + {displayTypeLabels[attribute.displayType]} +
+
+
+
+ + {attribute.isActive ? 'Activo' : 'Inactivo'} + + {editable && ( + <> + + {onRemoveAttribute && ( + + )} + + )} +
+
+ + {/* Preview of values */} + {attribute.values && attribute.values.length > 0 && ( +
+ {attribute.values.slice(0, 6).map((value) => ( + + {attribute.displayType === 'color' && renderColorSwatch(value.htmlColor)} + {value.name} + + ))} + {attribute.values.length > 6 && ( + + +{attribute.values.length - 6} mas + + )} +
+ )} +
+ ))} +
+ + {/* Add attribute button */} + {editable && onAddAttribute && ( + + )} + + {/* Attribute values modal */} + setIsModalOpen(false)} + title={selectedAttribute ? `Valores de: ${selectedAttribute.name}` : 'Valores'} + size="lg" + > + + {selectedAttribute && ( +
+
+
+ Codigo: + {selectedAttribute.code} +
+
+ Tipo: + {displayTypeLabels[selectedAttribute.displayType]} +
+
+ + {selectedAttribute.description && ( +

{selectedAttribute.description}

+ )} + +
+
+ Valores ({selectedAttribute.values?.length || 0}) +
+ {selectedAttribute.values && selectedAttribute.values.length > 0 ? ( +
+ {selectedAttribute.values.map((value) => renderValue(selectedAttribute, value))} +
+ ) : ( +

No hay valores definidos

+ )} +
+ + {editable && onAddValue && ( + + )} +
+ )} +
+ + + +
+
+ ); +} diff --git a/src/features/products/components/PricingTable.tsx b/src/features/products/components/PricingTable.tsx index d56545b..a09fccd 100644 --- a/src/features/products/components/PricingTable.tsx +++ b/src/features/products/components/PricingTable.tsx @@ -6,7 +6,7 @@ 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 { useProductPrices, usePricingHelpers } from '../hooks'; import type { ProductPrice, PriceType } from '../types'; const priceTypeConfig: Record = { @@ -35,20 +35,24 @@ export function PricingTable({ onEditPrice, className, }: PricingTableProps) { - const { data, isLoading, error, refetch } = useProductPrices(productId); - const { delete: deletePrice, isDeleting } = useProductPriceMutations(); + const { + prices, + isLoading, + error, + deletePrice + } = useProductPrices(productId); const { formatPrice, calculateMargin } = usePricingHelpers(); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [priceToDelete, setPriceToDelete] = useState(null); - - const prices = data?.data || []; + const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { if (!priceToDelete) return; + setIsDeleting(true); try { await deletePrice(priceToDelete.id); - refetch(); } finally { + setIsDeleting(false); setDeleteModalOpen(false); setPriceToDelete(null); } diff --git a/src/features/products/components/ProductTable.tsx b/src/features/products/components/ProductTable.tsx new file mode 100644 index 0000000..533232a --- /dev/null +++ b/src/features/products/components/ProductTable.tsx @@ -0,0 +1,261 @@ +import { useMemo } from 'react'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Badge } from '@components/atoms/Badge'; +import { Button } from '@components/atoms/Button'; +import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown'; +import { MoreVertical, Edit, Trash2, Eye, Copy, Power, PowerOff } from 'lucide-react'; +import type { Product } from '../types'; +import { usePricingHelpers } from '../hooks'; + +export interface ProductTableProps { + products: Product[]; + isLoading?: boolean; + total?: number; + page?: number; + limit?: number; + totalPages?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + onPageChange?: (page: number) => void; + onLimitChange?: (limit: number) => void; + onSort?: (key: string) => void; + onView?: (product: Product) => void; + onEdit?: (product: Product) => void; + onDelete?: (product: Product) => void; + onDuplicate?: (product: Product) => void; + onActivate?: (product: Product) => void; + onDeactivate?: (product: Product) => void; + selectedIds?: Set; + onSelect?: (id: string) => void; + onSelectAll?: () => void; +} + +const productTypeColors: Record = { + product: 'primary', + service: 'info', + consumable: 'warning', + kit: 'success', +}; + +const productTypeLabels: Record = { + product: 'Producto', + service: 'Servicio', + consumable: 'Consumible', + kit: 'Kit', +}; + +export function ProductTable({ + products, + isLoading = false, + total = 0, + page = 1, + limit = 10, + totalPages, + sortBy, + sortOrder = 'asc', + onPageChange, + onLimitChange, + onSort, + onView, + onEdit, + onDelete, + onDuplicate, + onActivate, + onDeactivate, + selectedIds, + onSelect, + onSelectAll, +}: ProductTableProps) { + const { formatPrice } = usePricingHelpers(); + + const getDropdownItems = (product: Product): DropdownItem[] => { + const items: DropdownItem[] = []; + + if (onView) { + items.push({ + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => onView(product), + }); + } + + if (onEdit) { + items.push({ + key: 'edit', + label: 'Editar', + icon: , + onClick: () => onEdit(product), + }); + } + + if (onDuplicate) { + items.push({ + key: 'duplicate', + label: 'Duplicar', + icon: , + onClick: () => onDuplicate(product), + }); + } + + if (product.isActive && onDeactivate) { + items.push({ + key: 'deactivate', + label: 'Desactivar', + icon: , + onClick: () => onDeactivate(product), + }); + } + + if (!product.isActive && onActivate) { + items.push({ + key: 'activate', + label: 'Activar', + icon: , + onClick: () => onActivate(product), + }); + } + + if (onDelete) { + items.push({ + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => onDelete(product), + }); + } + + return items; + }; + + const columns = useMemo[]>(() => [ + { + key: 'sku', + header: 'SKU', + accessor: 'sku', + sortable: true, + width: '120px', + render: (product) => ( + {product.sku} + ), + }, + { + key: 'name', + header: 'Producto', + sortable: true, + render: (product) => ( +
+ {product.imageUrl ? ( + {product.name} + ) : ( +
+ {product.name.charAt(0).toUpperCase()} +
+ )} +
+
{product.name}
+ {product.category && ( +
{product.category.name}
+ )} +
+
+ ), + }, + { + key: 'productType', + header: 'Tipo', + sortable: true, + width: '120px', + render: (product) => ( + + {productTypeLabels[product.productType] || product.productType} + + ), + }, + { + key: 'price', + header: 'Precio', + sortable: true, + align: 'right', + width: '130px', + render: (product) => ( + {formatPrice(product.price, product.currency)} + ), + }, + { + key: 'cost', + header: 'Costo', + sortable: true, + align: 'right', + width: '130px', + render: (product) => ( + {formatPrice(product.cost, product.currency)} + ), + }, + { + key: 'isActive', + header: 'Estado', + sortable: true, + width: '100px', + render: (product) => ( + + {product.isActive ? 'Activo' : 'Inactivo'} + + ), + }, + { + key: 'actions', + header: '', + width: '60px', + align: 'center', + render: (product) => ( +
e.stopPropagation()}> + + + + } + items={getDropdownItems(product)} + align="right" + /> +
+ ), + }, + ], [formatPrice, onView, onEdit, onDelete, onDuplicate, onActivate, onDeactivate]); + + return ( + product.id, + } : undefined} + onRowClick={onView} + rowClassName={(product) => !product.isActive ? 'bg-gray-50 opacity-75' : ''} + /> + ); +} diff --git a/src/features/products/components/index.ts b/src/features/products/components/index.ts index d070571..1edea3c 100644 --- a/src/features/products/components/index.ts +++ b/src/features/products/components/index.ts @@ -4,6 +4,9 @@ export type { ProductFormProps } from './ProductForm'; export { ProductCard } from './ProductCard'; export type { ProductCardProps } from './ProductCard'; +export { ProductTable } from './ProductTable'; +export type { ProductTableProps } from './ProductTable'; + export { CategoryTree } from './CategoryTree'; export type { CategoryTreeProps } from './CategoryTree'; @@ -12,3 +15,6 @@ export type { VariantSelectorProps } from './VariantSelector'; export { PricingTable } from './PricingTable'; export type { PricingTableProps } from './PricingTable'; + +export { AttributeEditor } from './AttributeEditor'; +export type { AttributeEditorProps } from './AttributeEditor'; diff --git a/src/features/products/hooks/index.ts b/src/features/products/hooks/index.ts index 057d5fe..9c82779 100644 --- a/src/features/products/hooks/index.ts +++ b/src/features/products/hooks/index.ts @@ -3,10 +3,6 @@ export { useProducts, useProduct, useProductBySku, - useProductByBarcode, - useSellableProducts, - usePurchasableProducts, - useProductMutations, useProductSearch, } from './useProducts'; export type { UseProductsOptions } from './useProducts'; @@ -18,7 +14,6 @@ export { useCategoryTree, useRootCategories, useChildCategories, - useCategoryMutations, useCategoryOptions, } from './useCategories'; export type { UseCategoriesOptions } from './useCategories'; @@ -27,6 +22,11 @@ export type { UseCategoriesOptions } from './useCategories'; export { useProductPrices, useProductPrice, - useProductPriceMutations, usePricingHelpers, } from './useProductPricing'; + +// Variants Hooks +export { + useProductVariants, + useProductVariant, +} from './useProductVariants'; diff --git a/src/features/products/hooks/useCategories.ts b/src/features/products/hooks/useCategories.ts index 95a582c..30c0acb 100644 --- a/src/features/products/hooks/useCategories.ts +++ b/src/features/products/hooks/useCategories.ts @@ -1,5 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { categoriesApi } from '../api/categories.api'; import type { ProductCategory, @@ -9,134 +8,250 @@ import type { CategoryTreeNode, } from '../types'; -const QUERY_KEY = 'product-categories'; - export interface UseCategoriesOptions extends CategorySearchParams { - enabled?: boolean; + autoFetch?: boolean; } // ==================== Categories List Hook ==================== export function useCategories(options: UseCategoriesOptions = {}) { - const { enabled = true, ...params } = options; + const { autoFetch = true, ...params } = options; + const [categories, setCategories] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(params.page || 1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - return useQuery({ - queryKey: [QUERY_KEY, 'list', params], - queryFn: () => categoriesApi.getAll(params), - enabled, - staleTime: 1000 * 60 * 10, // 10 minutes - categories change less frequently - }); + const fetchCategories = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await categoriesApi.getAll({ ...params, page }); + setCategories(response.data); + setTotal(response.meta.total); + setTotalPages(response.meta.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar categorías'); + } finally { + setIsLoading(false); + } + }, [params.search, params.parentId, params.isActive, params.limit, page]); + + useEffect(() => { + if (autoFetch) { + fetchCategories(); + } + }, [fetchCategories, autoFetch]); + + const createCategory = async (data: CreateCategoryDto) => { + setIsLoading(true); + try { + const newCategory = await categoriesApi.create(data); + await fetchCategories(); + return newCategory; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear categoría'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateCategory = async (id: string, data: UpdateCategoryDto) => { + setIsLoading(true); + try { + const updated = await categoriesApi.update(id, data); + await fetchCategories(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar categoría'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const deleteCategory = async (id: string) => { + setIsLoading(true); + try { + await categoriesApi.delete(id); + await fetchCategories(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar categoría'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + categories, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh: fetchCategories, + createCategory, + updateCategory, + deleteCategory, + }; } // ==================== 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, - }); + const [category, setCategory] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchCategory = useCallback(async () => { + if (!id) return; + setIsLoading(true); + setError(null); + try { + const data = await categoriesApi.getById(id); + setCategory(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar categoría'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + if (id) { + fetchCategory(); + } + }, [fetchCategory, id]); + + return { + category, + isLoading, + error, + refresh: fetchCategory, + }; } // ==================== 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 [allCategories, setAllCategories] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchAllCategories = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await categoriesApi.getAll({ limit: 1000 }); + setAllCategories(response.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar categorías'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchAllCategories(); + }, [fetchAllCategories]); const tree = useMemo(() => { - if (!data?.data) return []; - return categoriesApi.buildTree(data.data); - }, [data?.data]); + if (allCategories.length === 0) return []; + return categoriesApi.buildTree(allCategories); + }, [allCategories]); return { tree, - categories: data?.data || [], + categories: allCategories, isLoading, error, - refetch, + refresh: fetchAllCategories, }; } // ==================== Root Categories Hook ==================== export function useRootCategories() { - return useQuery({ - queryKey: [QUERY_KEY, 'roots'], - queryFn: () => categoriesApi.getRoots(), - staleTime: 1000 * 60 * 10, - }); + const [categories, setCategories] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRoots = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await categoriesApi.getRoots(); + setCategories(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar categorías raíz'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchRoots(); + }, [fetchRoots]); + + return { + categories, + isLoading, + error, + refresh: fetchRoots, + }; } // ==================== 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, - }); -} + const [categories, setCategories] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Category Mutations Hook ==================== + const fetchChildren = useCallback(async () => { + if (!parentId) return; + setIsLoading(true); + setError(null); + try { + const data = await categoriesApi.getChildren(parentId); + setCategories(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar subcategorías'); + } finally { + setIsLoading(false); + } + }, [parentId]); -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] }); - }, - }); + useEffect(() => { + if (parentId) { + fetchChildren(); + } + }, [fetchChildren, parentId]); 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, + categories, + isLoading, + error, + refresh: fetchChildren, }; } // ==================== Category Options Hook (for selects) ==================== export function useCategoryOptions() { - const { data, isLoading } = useCategories({ isActive: true, limit: 500 }); + const { categories, isLoading } = useCategories({ isActive: true, limit: 500 }); const options = useMemo(() => { - if (!data?.data) return []; - return data.data.map((cat: ProductCategory) => ({ + return categories.map((cat: ProductCategory) => ({ value: cat.id, label: cat.hierarchyPath ? `${cat.hierarchyPath} / ${cat.name}` : cat.name, category: cat, })); - }, [data?.data]); + }, [categories]); return { options, isLoading }; } diff --git a/src/features/products/hooks/useProductPricing.ts b/src/features/products/hooks/useProductPricing.ts index 12841ff..fe796cf 100644 --- a/src/features/products/hooks/useProductPricing.ts +++ b/src/features/products/hooks/useProductPricing.ts @@ -1,112 +1,134 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { api } from '@services/api/axios-instance'; -import type { ProductPrice, CreatePriceDto, UpdatePriceDto, PricesResponse } from '../types'; +import type { ProductPrice, CreatePriceDto, UpdatePriceDto } from '../types'; const PRICES_URL = '/api/v1/products/prices'; -const QUERY_KEY = 'product-prices'; - -// ==================== API Functions ==================== - -const pricesApi = { - getByProduct: async (productId: string): Promise => { - 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 => { - 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 => { - 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 => { - 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 => { - 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, - }); + const [prices, setPrices] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchPrices = useCallback(async () => { + if (!productId) return; + setIsLoading(true); + setError(null); + try { + const response = await api.get<{ success: boolean; data: ProductPrice[]; total: number }>( + `${PRICES_URL}?productId=${productId}` + ); + setPrices(response.data.data || []); + setTotal(response.data.total || 0); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar precios'); + } finally { + setIsLoading(false); + } + }, [productId]); + + useEffect(() => { + if (productId) { + fetchPrices(); + } + }, [fetchPrices, productId]); + + const createPrice = async (data: CreatePriceDto) => { + setIsLoading(true); + try { + const response = await api.post<{ success: boolean; data: ProductPrice }>(PRICES_URL, data); + if (!response.data.data) { + throw new Error('Error al crear precio'); + } + await fetchPrices(); + return response.data.data; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear precio'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updatePrice = async (priceId: string, data: UpdatePriceDto) => { + setIsLoading(true); + try { + const response = await api.patch<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${priceId}`, data); + if (!response.data.data) { + throw new Error('Error al actualizar precio'); + } + await fetchPrices(); + return response.data.data; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar precio'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const deletePrice = async (priceId: string) => { + setIsLoading(true); + try { + await api.delete(`${PRICES_URL}/${priceId}`); + await fetchPrices(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar precio'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + prices, + total, + isLoading, + error, + refresh: fetchPrices, + createPrice, + updatePrice, + deletePrice, + }; } // ==================== 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, - }); -} + const [price, setPrice] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Price Mutations Hook ==================== + const fetchPrice = useCallback(async () => { + if (!id) return; + setIsLoading(true); + setError(null); + try { + const response = await api.get<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`); + if (!response.data.data) { + throw new Error('Precio no encontrado'); + } + setPrice(response.data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar precio'); + } finally { + setIsLoading(false); + } + }, [id]); -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] }); - }, - }); + useEffect(() => { + if (id) { + fetchPrice(); + } + }, [fetchPrice, id]); 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, + price, + isLoading, + error, + refresh: fetchPrice, }; } diff --git a/src/features/products/hooks/useProductVariants.ts b/src/features/products/hooks/useProductVariants.ts new file mode 100644 index 0000000..405ceb3 --- /dev/null +++ b/src/features/products/hooks/useProductVariants.ts @@ -0,0 +1,121 @@ +import { useState, useEffect, useCallback } from 'react'; +import { productsApi } from '../api/products.api'; +import type { ProductVariant, CreateVariantDto, UpdateVariantDto } from '../types'; + +// ==================== Product Variants Hook ==================== + +export function useProductVariants(productId: string | null | undefined) { + const [variants, setVariants] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchVariants = useCallback(async () => { + if (!productId) return; + setIsLoading(true); + setError(null); + try { + const response = await productsApi.getVariants(productId); + setVariants(response.data || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar variantes'); + } finally { + setIsLoading(false); + } + }, [productId]); + + useEffect(() => { + if (productId) { + fetchVariants(); + } + }, [fetchVariants, productId]); + + const createVariant = async (data: CreateVariantDto) => { + if (!productId) throw new Error('Product ID is required'); + setIsLoading(true); + try { + const newVariant = await productsApi.createVariant(productId, data); + await fetchVariants(); + return newVariant; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear variante'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateVariant = async (variantId: string, data: UpdateVariantDto) => { + if (!productId) throw new Error('Product ID is required'); + setIsLoading(true); + try { + const updated = await productsApi.updateVariant(productId, variantId, data); + await fetchVariants(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar variante'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const deleteVariant = async (variantId: string) => { + if (!productId) throw new Error('Product ID is required'); + setIsLoading(true); + try { + await productsApi.deleteVariant(productId, variantId); + await fetchVariants(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar variante'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + variants, + isLoading, + error, + refresh: fetchVariants, + createVariant, + updateVariant, + deleteVariant, + }; +} + +// ==================== Single Variant Hook ==================== + +export function useProductVariant(productId: string, variantId: string | null | undefined) { + const [variant, setVariant] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchVariant = useCallback(async () => { + if (!productId || !variantId) return; + setIsLoading(true); + setError(null); + try { + const response = await productsApi.getVariants(productId); + const found = (response.data || []).find((v: ProductVariant) => v.id === variantId); + setVariant(found || null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar variante'); + } finally { + setIsLoading(false); + } + }, [productId, variantId]); + + useEffect(() => { + if (productId && variantId) { + fetchVariant(); + } + }, [fetchVariant, productId, variantId]); + + return { + variant, + isLoading, + error, + refresh: fetchVariant, + }; +} diff --git a/src/features/products/hooks/useProducts.ts b/src/features/products/hooks/useProducts.ts index 05b0035..4aa71b0 100644 --- a/src/features/products/hooks/useProducts.ts +++ b/src/features/products/hooks/useProducts.ts @@ -1,121 +1,171 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useEffect, useCallback } from 'react'; import { productsApi } from '../api/products.api'; import type { + Product, CreateProductDto, UpdateProductDto, ProductSearchParams, } from '../types'; -const QUERY_KEY = 'products'; - export interface UseProductsOptions extends ProductSearchParams { - enabled?: boolean; + autoFetch?: boolean; } // ==================== Products List Hook ==================== export function useProducts(options: UseProductsOptions = {}) { - const { enabled = true, ...params } = options; + const { autoFetch = true, ...params } = options; + const [products, setProducts] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(params.page || 1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - return useQuery({ - queryKey: [QUERY_KEY, 'list', params], - queryFn: () => productsApi.getAll(params), - enabled, - staleTime: 1000 * 60 * 5, // 5 minutes - }); + const fetchProducts = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await productsApi.getAll({ ...params, page }); + setProducts(response.data); + setTotal(response.meta.total); + setTotalPages(response.meta.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar productos'); + } finally { + setIsLoading(false); + } + }, [params.search, params.categoryId, params.isActive, params.limit, page]); + + useEffect(() => { + if (autoFetch) { + fetchProducts(); + } + }, [fetchProducts, autoFetch]); + + const createProduct = async (data: CreateProductDto) => { + setIsLoading(true); + try { + const newProduct = await productsApi.create(data); + await fetchProducts(); + return newProduct; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear producto'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateProduct = async (id: string, data: UpdateProductDto) => { + setIsLoading(true); + try { + const updated = await productsApi.update(id, data); + await fetchProducts(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar producto'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const deleteProduct = async (id: string) => { + setIsLoading(true); + try { + await productsApi.delete(id); + await fetchProducts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar producto'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + products, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh: fetchProducts, + createProduct, + updateProduct, + deleteProduct, + }; } // ==================== 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, - }); + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchProduct = useCallback(async () => { + if (!id) return; + setIsLoading(true); + setError(null); + try { + const data = await productsApi.getById(id); + setProduct(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar producto'); + } finally { + setIsLoading(false); + } + }, [id]); + + useEffect(() => { + if (id) { + fetchProduct(); + } + }, [fetchProduct, id]); + + return { + product, + isLoading, + error, + refresh: fetchProduct, + }; } // ==================== 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, - }); -} + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); -// ==================== Product by Barcode Hook ==================== + const fetchProduct = useCallback(async () => { + if (!sku) return; + setIsLoading(true); + setError(null); + try { + const data = await productsApi.getBySku(sku); + setProduct(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar producto por SKU'); + } finally { + setIsLoading(false); + } + }, [sku]); -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] }); - }, - }); + useEffect(() => { + if (sku) { + fetchProduct(); + } + }, [fetchProduct, sku]); 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, + isLoading, + error, + refresh: fetchProduct, }; } @@ -123,11 +173,34 @@ export function useProductMutations() { export function useProductSearch(searchTerm: string, options?: { limit?: number }) { const { limit = 10 } = options || {}; + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - 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 - }); + const search = useCallback(async () => { + if (searchTerm.length < 2) { + setResults([]); + return; + } + setIsLoading(true); + setError(null); + try { + const response = await productsApi.getAll({ search: searchTerm, limit }); + setResults(response.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error en búsqueda'); + } finally { + setIsLoading(false); + } + }, [searchTerm, limit]); + + useEffect(() => { + search(); + }, [search]); + + return { + results, + isLoading, + error, + }; } diff --git a/src/features/products/index.ts b/src/features/products/index.ts index affa251..e22f19f 100644 --- a/src/features/products/index.ts +++ b/src/features/products/index.ts @@ -1,6 +1,7 @@ // API export { productsApi } from './api/products.api'; export { categoriesApi } from './api/categories.api'; +export { pricingApi } from './api/pricing.api'; // Components export * from './components'; diff --git a/src/features/products/pages/CategoriesPage.tsx b/src/features/products/pages/CategoriesPage.tsx index d68db9e..fd2de1d 100644 --- a/src/features/products/pages/CategoriesPage.tsx +++ b/src/features/products/pages/CategoriesPage.tsx @@ -10,7 +10,7 @@ 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 { useCategories, useCategoryOptions } from '../hooks'; import { CategoryTree } from '../components'; import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, CategorySearchParams } from '../types'; @@ -28,7 +28,6 @@ export function CategoriesPage() { const [searchTerm, setSearchTerm] = useState(''); const [filters] = useState({ limit: 50, - offset: 0, }); const [page, setPage] = useState(1); const [viewMode, setViewMode] = useState<'tree' | 'table'>('tree'); @@ -41,17 +40,23 @@ export function CategoriesPage() { const queryParams: CategorySearchParams = { ...filters, search: searchTerm || undefined, - offset: (page - 1) * (filters.limit || 50), + page, }; - const { data, isLoading, refetch } = useCategories(queryParams); - const { create, update, delete: deleteCategory, isCreating, isUpdating, isDeleting } = useCategoryMutations(); + const { + categories, + total, + totalPages, + isLoading, + createCategory, + updateCategory, + deleteCategory + } = useCategories(queryParams); const { options: categoryOptions } = useCategoryOptions(); - - const categories = data?.data || []; - const total = data?.total || 0; + const [isCreating, setIsCreating] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const limit = filters.limit || 50; - const totalPages = Math.ceil(total / limit); const { register, @@ -108,21 +113,34 @@ export function CategoriesPage() { }; if (editingCategory) { - await update({ id: editingCategory.id, data: cleanData as UpdateCategoryDto }); + setIsUpdating(true); + try { + await updateCategory(editingCategory.id, cleanData as UpdateCategoryDto); + } finally { + setIsUpdating(false); + } } else { - await create(cleanData as CreateCategoryDto); + setIsCreating(true); + try { + await createCategory(cleanData as CreateCategoryDto); + } finally { + setIsCreating(false); + } } setFormModalOpen(false); - refetch(); }; const handleDelete = async () => { if (!categoryToDelete) return; - await deleteCategory(categoryToDelete.id); - setDeleteModalOpen(false); - setCategoryToDelete(null); - refetch(); + setIsDeleting(true); + try { + await deleteCategory(categoryToDelete.id); + } finally { + setIsDeleting(false); + setDeleteModalOpen(false); + setCategoryToDelete(null); + } }; const columns: Column[] = [ diff --git a/src/features/products/pages/PricingPage.tsx b/src/features/products/pages/PricingPage.tsx new file mode 100644 index 0000000..07896c8 --- /dev/null +++ b/src/features/products/pages/PricingPage.tsx @@ -0,0 +1,472 @@ +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Plus, DollarSign, Calendar } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@components/atoms/Button'; +import { Badge } from '@components/atoms/Badge'; +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 { Spinner } from '@components/atoms/Spinner'; +import { + useProduct, + useProductPrices, + usePricingHelpers, +} from '../hooks'; +import type { ProductPrice, CreatePriceDto, UpdatePriceDto, PriceType } from '../types'; + +const priceSchema = z.object({ + priceType: z.enum(['standard', 'wholesale', 'retail', 'promo']), + priceListName: z.string().max(100, 'Maximo 100 caracteres').optional(), + price: 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'), + minQuantity: z.coerce.number().min(1, 'Minimo 1'), + validFrom: z.string().optional(), + validTo: z.string().optional(), + isActive: z.boolean(), +}); + +type PriceFormData = z.infer; + +const priceTypeOptions: { value: PriceType; label: string }[] = [ + { value: 'standard', label: 'Estandar' }, + { value: 'wholesale', label: 'Mayoreo' }, + { value: 'retail', label: 'Menudeo' }, + { value: 'promo', label: 'Promocion' }, +]; + +const priceTypeColors: Record = { + standard: 'primary', + wholesale: 'info', + retail: 'success', + promo: 'warning', +}; + +const priceTypeLabels: Record = { + standard: 'Estandar', + wholesale: 'Mayoreo', + retail: 'Menudeo', + promo: 'Promocion', +}; + +export function PricingPage() { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + const [formModalOpen, setFormModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [editingPrice, setEditingPrice] = useState(null); + const [priceToDelete, setPriceToDelete] = useState(null); + + const { product, isLoading: productLoading } = useProduct(productId); + const { + prices, + isLoading: pricesLoading, + createPrice, + updatePrice, + deletePrice + } = useProductPrices(productId); + const { formatPrice } = usePricingHelpers(); + const [isCreating, setIsCreating] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const isLoading = productLoading || pricesLoading; + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(priceSchema), + defaultValues: { + priceType: 'standard', + priceListName: '', + price: 0, + currency: 'MXN', + minQuantity: 1, + validFrom: '', + validTo: '', + isActive: true, + }, + }); + + const openCreateModal = () => { + setEditingPrice(null); + reset({ + priceType: 'standard', + priceListName: '', + price: product?.price || 0, + currency: product?.currency || 'MXN', + minQuantity: 1, + validFrom: new Date().toISOString().split('T')[0], + validTo: '', + isActive: true, + }); + setFormModalOpen(true); + }; + + const openEditModal = (price: ProductPrice) => { + setEditingPrice(price); + reset({ + priceType: price.priceType, + priceListName: price.priceListName || '', + price: price.price, + currency: price.currency, + minQuantity: price.minQuantity, + validFrom: price.validFrom ? price.validFrom.split('T')[0] : '', + validTo: price.validTo ? price.validTo.split('T')[0] : '', + isActive: price.isActive, + }); + setFormModalOpen(true); + }; + + const openDeleteModal = (price: ProductPrice) => { + setPriceToDelete(price); + setDeleteModalOpen(true); + }; + + const handleFormSubmit = async (data: PriceFormData) => { + const cleanData = { + ...data, + priceListName: data.priceListName || undefined, + validFrom: data.validFrom || undefined, + validTo: data.validTo || undefined, + }; + + if (editingPrice) { + setIsUpdating(true); + try { + await updatePrice(editingPrice.id, cleanData as UpdatePriceDto); + } finally { + setIsUpdating(false); + } + } else { + setIsCreating(true); + try { + await createPrice({ ...cleanData, productId } as CreatePriceDto); + } finally { + setIsCreating(false); + } + } + + setFormModalOpen(false); + }; + + const handleDelete = async () => { + if (!priceToDelete) return; + setIsDeleting(true); + try { + await deletePrice(priceToDelete.id); + } finally { + setIsDeleting(false); + setDeleteModalOpen(false); + setPriceToDelete(null); + } + }; + + const columns: Column[] = [ + { + key: 'priceType', + header: 'Tipo', + width: '120px', + render: (row) => ( + + {priceTypeLabels[row.priceType]} + + ), + }, + { + 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: 'Cantidad minima', + align: 'center', + render: (row) => row.minQuantity, + }, + { + key: 'validFrom', + header: 'Vigencia', + render: (row) => ( +
+ + + {row.validFrom ? new Date(row.validFrom).toLocaleDateString('es-MX') : 'Sin inicio'} + {' - '} + {row.validTo ? new Date(row.validTo).toLocaleDateString('es-MX') : 'Sin fin'} + +
+ ), + }, + { + key: 'isActive', + header: 'Estado', + width: '100px', + render: (row) => ( + + {row.isActive ? 'Activo' : 'Inactivo'} + + ), + }, + { + key: 'actions', + header: '', + align: 'right', + width: '100px', + render: (row) => ( +
+ + +
+ ), + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ Precios: {product?.name || 'Producto'} +

+

+ Gestiona los precios y listas de precios para este producto +

+
+
+ +
+ + {/* Summary */} + {product && ( +
+ + +
+
+ +
+
+

Precio base

+

+ {formatPrice(product.price, product.currency)} +

+
+
+
+
+ + +
+
+ +
+
+

Costo

+

+ {formatPrice(product.cost, product.currency)} +

+
+
+
+
+ + +
+
+ +
+
+

Precios configurados

+

{prices.length}

+
+
+
+
+
+ )} + + {/* Prices Table */} + + + Lista de Precios + + + + + + + {/* Form Modal */} + setFormModalOpen(false)} + title={editingPrice ? 'Editar Precio' : 'Nuevo Precio'} + size="lg" + > +
+
+ + + + + + + +
+ +
+ +
+ $ + +
+
+ + + + + + + + +
+ +
+ + + + + + + +
+ + + +
+ + +
+
+
+ + {/* Delete Confirmation */} + { + setDeleteModalOpen(false); + setPriceToDelete(null); + }} + onConfirm={handleDelete} + title="Eliminar precio" + message={`¿Estas seguro de eliminar este precio de tipo "${priceToDelete ? priceTypeLabels[priceToDelete.priceType] : ''}"?`} + confirmText="Eliminar" + variant="danger" + isLoading={isDeleting} + /> +
+ ); +} diff --git a/src/features/products/pages/ProductDetailPage.tsx b/src/features/products/pages/ProductDetailPage.tsx index becf6a4..a51a237 100644 --- a/src/features/products/pages/ProductDetailPage.tsx +++ b/src/features/products/pages/ProductDetailPage.tsx @@ -18,9 +18,9 @@ import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/ 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 { useProduct, useProducts, usePricingHelpers } from '../hooks'; import { ProductForm, PricingTable } from '../components'; -import type { Product, ProductType, UpdateProductDto } from '../types'; +import type { ProductType, UpdateProductDto } from '../types'; const productTypeLabels: Record = { product: 'Producto', @@ -35,26 +35,33 @@ export function ProductDetailPage() { 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 { product, isLoading, error, refresh } = useProduct(id); + const { updateProduct, deleteProduct } = useProducts({ autoFetch: false }); const { formatPrice, calculateMargin } = usePricingHelpers(); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const handleUpdate = async (data: UpdateProductDto) => { if (!id) return; - await update({ id, data }); - setIsEditing(false); - refetch(); + setIsUpdating(true); + try { + await updateProduct(id, data); + setIsEditing(false); + refresh(); + } finally { + setIsUpdating(false); + } }; const handleDelete = async () => { if (!id) return; - await deleteProduct(id); - navigate('/products'); + setIsDeleting(true); + try { + await deleteProduct(id); + navigate('/products'); + } finally { + setIsDeleting(false); + } }; const handleDuplicate = () => { diff --git a/src/features/products/pages/ProductsPage.tsx b/src/features/products/pages/ProductsPage.tsx index 9e4c60e..8f7eb8a 100644 --- a/src/features/products/pages/ProductsPage.tsx +++ b/src/features/products/pages/ProductsPage.tsx @@ -10,7 +10,7 @@ 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 { useProducts, useCategoryOptions, usePricingHelpers } from '../hooks'; import { ProductCard } from '../components'; import type { Product, ProductType, ProductSearchParams } from '../types'; @@ -36,7 +36,6 @@ export function ProductsPage() { const [searchTerm, setSearchTerm] = useState(''); const [filters, setFilters] = useState({ limit: 25, - offset: 0, }); const [page, setPage] = useState(1); const [deleteModalOpen, setDeleteModalOpen] = useState(false); @@ -49,16 +48,20 @@ export function ProductsPage() { const queryParams: ProductSearchParams = { ...filters, search: debouncedSearch || undefined, - offset: (page - 1) * (filters.limit || 25), + page, }; - const { data, isLoading, error, refetch } = useProducts(queryParams); - const { delete: deleteProduct, isDeleting } = useProductMutations(); - - const products = data?.data || []; - const total = data?.total || 0; + const { + products, + total, + totalPages, + isLoading, + error, + refresh, + deleteProduct + } = useProducts(queryParams); + const [isDeleting, setIsDeleting] = useState(false); const limit = filters.limit || 25; - const totalPages = Math.ceil(total / limit); const handlePageChange = useCallback((newPage: number) => { setPage(newPage); @@ -88,10 +91,11 @@ export function ProductsPage() { const handleDelete = async () => { if (!productToDelete) return; + setIsDeleting(true); try { await deleteProduct(productToDelete.id); - refetch(); } finally { + setIsDeleting(false); setDeleteModalOpen(false); setProductToDelete(null); } @@ -284,7 +288,7 @@ export function ProductsPage() {

Error al cargar productos

-
diff --git a/src/features/products/pages/index.ts b/src/features/products/pages/index.ts index b34f4ac..0d827b3 100644 --- a/src/features/products/pages/index.ts +++ b/src/features/products/pages/index.ts @@ -1,3 +1,4 @@ export { ProductsPage } from './ProductsPage'; export { ProductDetailPage } from './ProductDetailPage'; export { CategoriesPage } from './CategoriesPage'; +export { PricingPage } from './PricingPage'; diff --git a/src/features/products/types/index.ts b/src/features/products/types/index.ts index 8f0b85f..14e260a 100644 --- a/src/features/products/types/index.ts +++ b/src/features/products/types/index.ts @@ -44,6 +44,7 @@ export interface Product { createdBy: string | null; updatedAt: string; updatedBy: string | null; + deletedAt?: string | null; } export interface CreateProductDto { @@ -57,7 +58,25 @@ export interface CreateProductDto { price?: number; cost?: number; currency?: string; + taxIncluded?: boolean; taxRate?: number; + taxCode?: string; + uom?: string; + uomPurchase?: string; + uomConversion?: number; + trackInventory?: boolean; + minStock?: number; + maxStock?: number; + reorderPoint?: number; + leadTimeDays?: number; + weight?: number; + length?: number; + width?: number; + height?: number; + volume?: number; + imageUrl?: string; + images?: string[]; + attributes?: Record; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; @@ -74,21 +93,43 @@ export interface UpdateProductDto { 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; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; } -export interface ProductSearchParams { +export interface ProductFilters { search?: string; categoryId?: string; productType?: ProductType; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; + minPrice?: number; + maxPrice?: number; + page?: number; limit?: number; - offset?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; } // ==================== Category Types ==================== @@ -108,6 +149,8 @@ export interface ProductCategory { isActive: boolean; createdAt: string; updatedAt: string; + deletedAt?: string | null; + children?: ProductCategory[]; } export interface CreateCategoryDto { @@ -115,6 +158,8 @@ export interface CreateCategoryDto { name: string; description?: string; parentId?: string; + imageUrl?: string; + sortOrder?: number; isActive?: boolean; } @@ -123,15 +168,19 @@ export interface UpdateCategoryDto { name?: string; description?: string | null; parentId?: string | null; + imageUrl?: string | null; + sortOrder?: number; isActive?: boolean; } -export interface CategorySearchParams { +export interface CategoryFilters { search?: string; parentId?: string; isActive?: boolean; + page?: number; limit?: number; - offset?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; } // ==================== Variant Types ==================== @@ -155,6 +204,28 @@ export interface ProductVariant { updatedBy: string | null; } +export interface CreateVariantDto { + sku: string; + name: string; + barcode?: string; + priceExtra?: number; + costExtra?: number; + stockQty?: number; + imageUrl?: string; + isActive?: boolean; +} + +export interface UpdateVariantDto { + sku?: string; + name?: string; + barcode?: string | null; + priceExtra?: number; + costExtra?: number; + stockQty?: number; + imageUrl?: string | null; + isActive?: boolean; +} + // ==================== Price Types ==================== export type PriceType = 'standard' | 'wholesale' | 'retail' | 'promo'; @@ -176,7 +247,6 @@ export interface ProductPrice { } export interface CreatePriceDto { - productId: string; priceType?: PriceType; priceListName?: string; price: number; @@ -232,6 +302,42 @@ export interface ProductAttributeValue { updatedAt: string; } +export interface CreateAttributeDto { + code: string; + name: string; + description?: string; + displayType?: AttributeDisplayType; + isActive?: boolean; + sortOrder?: number; +} + +export interface UpdateAttributeDto { + code?: string; + name?: string; + description?: string | null; + displayType?: AttributeDisplayType; + isActive?: boolean; + sortOrder?: number; +} + +export interface CreateAttributeValueDto { + code?: string; + name: string; + htmlColor?: string; + imageUrl?: string; + isActive?: boolean; + sortOrder?: number; +} + +export interface UpdateAttributeValueDto { + code?: string; + name?: string; + htmlColor?: string | null; + imageUrl?: string | null; + isActive?: boolean; + sortOrder?: number; +} + // ==================== Supplier Types ==================== export interface ProductSupplier { @@ -251,20 +357,47 @@ export interface ProductSupplier { updatedAt: string; } +export interface CreateSupplierDto { + supplierId: string; + supplierSku?: string; + supplierName?: string; + purchasePrice?: number; + currency?: string; + minOrderQty?: number; + leadTimeDays?: number; + isPreferred?: boolean; + isActive?: boolean; +} + +export interface UpdateSupplierDto { + supplierId?: string; + supplierSku?: string | null; + supplierName?: string | null; + purchasePrice?: number | null; + currency?: string; + minOrderQty?: number; + leadTimeDays?: number; + isPreferred?: boolean; + isActive?: boolean; +} + // ==================== Response Types ==================== +export interface PaginationMeta { + total: number; + page: number; + limit: number; + totalPages: number; +} + export interface ProductsResponse { data: Product[]; - total: number; - limit: number; - offset: number; + meta: PaginationMeta; } export interface CategoriesResponse { data: ProductCategory[]; - total: number; - limit: number; - offset: number; + meta: PaginationMeta; } export interface PricesResponse { @@ -272,8 +405,26 @@ export interface PricesResponse { total: number; } +export interface VariantsResponse { + data: ProductVariant[]; + total: number; +} + +export interface AttributesResponse { + data: ProductAttribute[]; + total: number; +} + // ==================== Tree Node Types ==================== export interface CategoryTreeNode extends ProductCategory { children: CategoryTreeNode[]; } + +// ==================== Backward Compatibility Aliases ==================== + +/** @deprecated Use ProductFilters instead */ +export type ProductSearchParams = ProductFilters; + +/** @deprecated Use CategoryFilters instead */ +export type CategorySearchParams = CategoryFilters; diff --git a/src/features/warehouses/api/index.ts b/src/features/warehouses/api/index.ts index ce421cf..2c89ae9 100644 --- a/src/features/warehouses/api/index.ts +++ b/src/features/warehouses/api/index.ts @@ -1 +1 @@ -export { warehousesApi, locationsApi } from './warehouses.api'; +export { warehousesApi, locationsApi, zonesApi } from './warehouses.api'; diff --git a/src/features/warehouses/api/warehouses.api.ts b/src/features/warehouses/api/warehouses.api.ts index 8de4778..98d0237 100644 --- a/src/features/warehouses/api/warehouses.api.ts +++ b/src/features/warehouses/api/warehouses.api.ts @@ -3,10 +3,13 @@ import type { ApiResponse } from '@shared/types/api.types'; import type { Warehouse, WarehouseLocation, + WarehouseZone, CreateWarehouseDto, UpdateWarehouseDto, CreateLocationDto, UpdateLocationDto, + CreateZoneDto, + UpdateZoneDto, WarehouseFilters, LocationFilters, WarehousesResponse, @@ -159,3 +162,63 @@ export const locationsApi = { } }, }; + +// ==================== Zones API ==================== + +export const zonesApi = { + // List all zones for a warehouse + getByWarehouse: async (warehouseId: string): Promise => { + const response = await api.get>( + `${BASE_URL}/${warehouseId}/zones` + ); + if (!response.data.success) { + throw new Error(response.data.error || 'Error al obtener zonas del almacen'); + } + return response.data.data || []; + }, + + // Get zone by ID + getById: async (warehouseId: string, zoneId: string): Promise => { + const response = await api.get>( + `${BASE_URL}/${warehouseId}/zones/${zoneId}` + ); + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error || 'Zona no encontrada'); + } + return response.data.data; + }, + + // Create zone + create: async (warehouseId: string, data: CreateZoneDto): Promise => { + const response = await api.post>( + `${BASE_URL}/${warehouseId}/zones`, + data + ); + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error || 'Error al crear zona'); + } + return response.data.data; + }, + + // Update zone + update: async (warehouseId: string, zoneId: string, data: UpdateZoneDto): Promise => { + const response = await api.patch>( + `${BASE_URL}/${warehouseId}/zones/${zoneId}`, + data + ); + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error || 'Error al actualizar zona'); + } + return response.data.data; + }, + + // Delete zone + delete: async (warehouseId: string, zoneId: string): Promise => { + const response = await api.delete( + `${BASE_URL}/${warehouseId}/zones/${zoneId}` + ); + if (!response.data.success) { + throw new Error(response.data.error || 'Error al eliminar zona'); + } + }, +}; diff --git a/src/features/warehouses/hooks/index.ts b/src/features/warehouses/hooks/index.ts index dfb91b3..9c980c0 100644 --- a/src/features/warehouses/hooks/index.ts +++ b/src/features/warehouses/hooks/index.ts @@ -15,3 +15,10 @@ export { useLocationTree, } from './useLocations'; export type { UseLocationsOptions } from './useLocations'; + +// Zones hooks +export { + useZones, + useZone, +} from './useZones'; +export type { UseZonesOptions } from './useZones'; diff --git a/src/features/warehouses/hooks/useZones.ts b/src/features/warehouses/hooks/useZones.ts new file mode 100644 index 0000000..0895990 --- /dev/null +++ b/src/features/warehouses/hooks/useZones.ts @@ -0,0 +1,169 @@ +import { useState, useEffect, useCallback } from 'react'; +import { zonesApi } from '../api'; +import type { + WarehouseZone, + CreateZoneDto, + UpdateZoneDto, +} from '../types'; + +// ==================== Zones List Hook ==================== + +export interface UseZonesOptions { + autoFetch?: boolean; +} + +export function useZones(warehouseId: string | null, options: UseZonesOptions = {}) { + const { autoFetch = true } = options; + const [zones, setZones] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchZones = useCallback(async () => { + if (!warehouseId) { + setZones([]); + setTotal(0); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await zonesApi.getByWarehouse(warehouseId); + setZones(data); + setTotal(data.length); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar zonas'); + } finally { + setIsLoading(false); + } + }, [warehouseId]); + + useEffect(() => { + if (autoFetch) { + fetchZones(); + } + }, [fetchZones, autoFetch]); + + const createZone = useCallback(async (data: CreateZoneDto) => { + if (!warehouseId) { + throw new Error('Se requiere un almacen para crear una zona'); + } + setIsLoading(true); + setError(null); + try { + const newZone = await zonesApi.create(warehouseId, data); + await fetchZones(); + return newZone; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Error al crear zona'; + setError(errorMsg); + throw err; + } finally { + setIsLoading(false); + } + }, [warehouseId, fetchZones]); + + const updateZone = useCallback(async (zoneId: string, data: UpdateZoneDto) => { + if (!warehouseId) { + throw new Error('Se requiere un almacen para actualizar una zona'); + } + setIsLoading(true); + setError(null); + try { + const updated = await zonesApi.update(warehouseId, zoneId, data); + await fetchZones(); + return updated; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Error al actualizar zona'; + setError(errorMsg); + throw err; + } finally { + setIsLoading(false); + } + }, [warehouseId, fetchZones]); + + const deleteZone = useCallback(async (zoneId: string) => { + if (!warehouseId) { + throw new Error('Se requiere un almacen para eliminar una zona'); + } + setIsLoading(true); + setError(null); + try { + await zonesApi.delete(warehouseId, zoneId); + await fetchZones(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Error al eliminar zona'; + setError(errorMsg); + throw err; + } finally { + setIsLoading(false); + } + }, [warehouseId, fetchZones]); + + return { + zones, + total, + isLoading, + error, + refresh: fetchZones, + createZone, + updateZone, + deleteZone, + }; +} + +// ==================== Single Zone Hook ==================== + +export function useZone(warehouseId: string | null, zoneId: string | null) { + const [zone, setZone] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchZone = useCallback(async () => { + if (!warehouseId || !zoneId) { + setZone(null); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await zonesApi.getById(warehouseId, zoneId); + setZone(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar zona'); + } finally { + setIsLoading(false); + } + }, [warehouseId, zoneId]); + + useEffect(() => { + fetchZone(); + }, [fetchZone]); + + const updateZone = useCallback(async (data: UpdateZoneDto) => { + if (!warehouseId || !zoneId) return; + setIsLoading(true); + setError(null); + try { + const updated = await zonesApi.update(warehouseId, zoneId, data); + setZone(updated); + return updated; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Error al actualizar zona'; + setError(errorMsg); + throw err; + } finally { + setIsLoading(false); + } + }, [warehouseId, zoneId]); + + return { + zone, + isLoading, + error, + refresh: fetchZone, + updateZone, + }; +} diff --git a/src/features/warehouses/index.ts b/src/features/warehouses/index.ts index 1df04fe..c4750f7 100644 --- a/src/features/warehouses/index.ts +++ b/src/features/warehouses/index.ts @@ -1,5 +1,5 @@ // API -export { warehousesApi, locationsApi } from './api'; +export { warehousesApi, locationsApi, zonesApi } from './api'; // Types export type { @@ -35,8 +35,10 @@ export { useAllLocations, useLocation, useLocationTree, + useZones, + useZone, } from './hooks'; -export type { UseWarehousesOptions, UseLocationsOptions } from './hooks'; +export type { UseWarehousesOptions, UseLocationsOptions, UseZonesOptions } from './hooks'; // Components export {