[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 01:00:46 -06:00
parent b5cffbff5f
commit b9068be3d9
30 changed files with 2630 additions and 700 deletions

View File

@ -1,20 +1,41 @@
import { useQuery } from '@tanstack/react-query'; import { useState, useEffect, useCallback } from 'react';
import { companiesApi } from '../api'; import { companiesApi } from '../api';
import type { BranchesResponse } from '../types'; import type { Branch } from '../types';
const QUERY_KEY = 'companies';
// ==================== Company Branches Hook ==================== // ==================== Company Branches Hook ====================
export function useCompanyBranches(companyId: string | null | undefined) { export function useCompanyBranches(companyId: string | null | undefined) {
return useQuery({ const [branches, setBranches] = useState<Branch[]>([]);
queryKey: [QUERY_KEY, 'branches', companyId], const [total, setTotal] = useState(0);
queryFn: () => companiesApi.getBranches(companyId as string), const [isLoading, setIsLoading] = useState(false);
enabled: !!companyId, const [error, setError] = useState<string | null>(null);
staleTime: 1000 * 60 * 5, // 5 minutes
select: (response: BranchesResponse) => ({ const fetchBranches = useCallback(async () => {
branches: response.data, if (!companyId) return;
total: response.total, 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,
};
} }

View File

@ -1,38 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect, useCallback } from 'react';
import { companiesApi } from '../api'; import { companiesApi } from '../api';
import type { CompanySettings } from '../types'; import type { CompanySettings } from '../types';
const QUERY_KEY = 'companies';
// ==================== Company Settings Hook ==================== // ==================== Company Settings Hook ====================
export function useCompanySettings(companyId: string | null | undefined) { export function useCompanySettings(companyId: string | null | undefined) {
return useQuery({ const [settings, setSettings] = useState<CompanySettings | null>(null);
queryKey: [QUERY_KEY, 'settings', companyId], const [isLoading, setIsLoading] = useState(false);
queryFn: () => companiesApi.getSettings(companyId as string), const [error, setError] = useState<string | null>(null);
enabled: !!companyId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== 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() { useEffect(() => {
const queryClient = useQueryClient(); if (companyId) {
fetchSettings();
}
}, [fetchSettings, companyId]);
const updateMutation = useMutation({ const updateSettings = async (newSettings: CompanySettings) => {
mutationFn: ({ companyId, settings }: { companyId: string; settings: CompanySettings }) => if (!companyId) throw new Error('Company ID required');
companiesApi.updateSettings(companyId, settings), setIsLoading(true);
onSuccess: (_: unknown, variables: { companyId: string; settings: CompanySettings }) => { try {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] }); const updated = await companiesApi.updateSettings(companyId, newSettings);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'settings', variables.companyId] }); setSettings(updated);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.companyId] }); return updated;
}, } catch (err) {
}); setError(err instanceof Error ? err.message : 'Error al actualizar configuración');
throw err;
} finally {
setIsLoading(false);
}
};
return { return {
updateSettings: updateMutation.mutateAsync, settings,
isUpdating: updateMutation.isPending, isLoading,
updateError: updateMutation.error, error,
refresh: fetchSettings,
updateSettings,
}; };
} }

View File

@ -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 { addressesApi } from '../api/addresses.api';
import type { import type {
PartnerAddress,
CreatePartnerAddressDto, CreatePartnerAddressDto,
UpdatePartnerAddressDto, UpdatePartnerAddressDto,
} from '../types'; } from '../types';
const QUERY_KEY = 'partner-addresses';
// ==================== Addresses List Hook ==================== // ==================== Addresses List Hook ====================
export function usePartnerAddresses(partnerId: string | null | undefined) { export function usePartnerAddresses(partnerId: string | null | undefined) {
return useQuery({ const [addresses, setAddresses] = useState<PartnerAddress[]>([]);
queryKey: [QUERY_KEY, partnerId], const [isLoading, setIsLoading] = useState(false);
queryFn: () => addressesApi.getByPartnerId(partnerId as string), const [error, setError] = useState<string | null>(null);
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== 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) { useEffect(() => {
const queryClient = useQueryClient(); if (partnerId) {
fetchAddresses();
}
}, [fetchAddresses, partnerId]);
const createMutation = useMutation({ const createAddress = async (data: CreatePartnerAddressDto) => {
mutationFn: (data: CreatePartnerAddressDto) => addressesApi.create(partnerId, data), if (!partnerId) throw new Error('Partner ID required');
onSuccess: () => { setIsLoading(true);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); try {
}, const newAddress = await addressesApi.create(partnerId, data);
}); await fetchAddresses();
return newAddress;
const updateMutation = useMutation({ } catch (err) {
mutationFn: ({ addressId, data }: { addressId: string; data: UpdatePartnerAddressDto }) => setError(err instanceof Error ? err.message : 'Error al crear dirección');
addressesApi.update(partnerId, addressId, data), throw err;
onSuccess: () => { } finally {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); setIsLoading(false);
}, }
});
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,
}; };
}
// ==================== 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 deleteAddress = async (addressId: string) => {
const { data, isLoading, error, refetch } = usePartnerAddresses(partnerId); if (!partnerId) throw new Error('Partner ID required');
const mutations = usePartnerAddressMutations(partnerId || ''); 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 { return {
addresses: data || [], addresses,
isLoading, isLoading,
error, error,
refetch, refresh: fetchAddresses,
...mutations, createAddress,
// Disable mutations if no partnerId updateAddress,
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, deleteAddress,
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, setDefaultAddress,
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
}; };
} }

View File

@ -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 { bankAccountsApi } from '../api/bankAccounts.api';
import type { import type {
PartnerBankAccount,
CreatePartnerBankAccountDto, CreatePartnerBankAccountDto,
UpdatePartnerBankAccountDto, UpdatePartnerBankAccountDto,
} from '../types'; } from '../types';
const QUERY_KEY = 'partner-bank-accounts';
// ==================== Bank Accounts List Hook ==================== // ==================== Bank Accounts List Hook ====================
export function usePartnerBankAccounts(partnerId: string | null | undefined) { export function usePartnerBankAccounts(partnerId: string | null | undefined) {
return useQuery({ const [bankAccounts, setBankAccounts] = useState<PartnerBankAccount[]>([]);
queryKey: [QUERY_KEY, partnerId], const [isLoading, setIsLoading] = useState(false);
queryFn: () => bankAccountsApi.getByPartnerId(partnerId as string), const [error, setError] = useState<string | null>(null);
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== 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) { useEffect(() => {
const queryClient = useQueryClient(); if (partnerId) {
fetchBankAccounts();
}
}, [fetchBankAccounts, partnerId]);
const createMutation = useMutation({ const createBankAccount = async (data: CreatePartnerBankAccountDto) => {
mutationFn: (data: CreatePartnerBankAccountDto) => bankAccountsApi.create(partnerId, data), if (!partnerId) throw new Error('Partner ID required');
onSuccess: () => { setIsLoading(true);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); try {
}, const newAccount = await bankAccountsApi.create(partnerId, data);
}); await fetchBankAccounts();
return newAccount;
const updateMutation = useMutation({ } catch (err) {
mutationFn: ({ accountId, data }: { accountId: string; data: UpdatePartnerBankAccountDto }) => setError(err instanceof Error ? err.message : 'Error al crear cuenta bancaria');
bankAccountsApi.update(partnerId, accountId, data), throw err;
onSuccess: () => { } finally {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); setIsLoading(false);
}, }
});
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,
}; };
}
// ==================== 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 deleteBankAccount = async (accountId: string) => {
const { data, isLoading, error, refetch } = usePartnerBankAccounts(partnerId); if (!partnerId) throw new Error('Partner ID required');
const mutations = usePartnerBankAccountMutations(partnerId || ''); 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 { return {
bankAccounts: data || [], bankAccounts,
isLoading, isLoading,
error, error,
refetch, refresh: fetchBankAccounts,
...mutations, createBankAccount,
// Disable mutations if no partnerId updateBankAccount,
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, deleteBankAccount,
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, verifyBankAccount,
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); }, setDefaultBankAccount,
verify: partnerId ? mutations.verify : async () => { throw new Error('Partner ID required'); },
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
}; };
} }

View File

@ -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 { contactsApi } from '../api/contacts.api';
import type { import type {
PartnerContact,
CreatePartnerContactDto, CreatePartnerContactDto,
UpdatePartnerContactDto, UpdatePartnerContactDto,
} from '../types'; } from '../types';
const QUERY_KEY = 'partner-contacts';
// ==================== Contacts List Hook ==================== // ==================== Contacts List Hook ====================
export function usePartnerContacts(partnerId: string | null | undefined) { export function usePartnerContacts(partnerId: string | null | undefined) {
return useQuery({ const [contacts, setContacts] = useState<PartnerContact[]>([]);
queryKey: [QUERY_KEY, partnerId], const [isLoading, setIsLoading] = useState(false);
queryFn: () => contactsApi.getByPartnerId(partnerId as string), const [error, setError] = useState<string | null>(null);
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== 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) { useEffect(() => {
const queryClient = useQueryClient(); if (partnerId) {
fetchContacts();
}
}, [fetchContacts, partnerId]);
const createMutation = useMutation({ const createContact = async (data: CreatePartnerContactDto) => {
mutationFn: (data: CreatePartnerContactDto) => contactsApi.create(partnerId, data), if (!partnerId) throw new Error('Partner ID required');
onSuccess: () => { setIsLoading(true);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); try {
}, const newContact = await contactsApi.create(partnerId, data);
}); await fetchContacts();
return newContact;
const updateMutation = useMutation({ } catch (err) {
mutationFn: ({ contactId, data }: { contactId: string; data: UpdatePartnerContactDto }) => setError(err instanceof Error ? err.message : 'Error al crear contacto');
contactsApi.update(partnerId, contactId, data), throw err;
onSuccess: () => { } finally {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] }); setIsLoading(false);
}, }
});
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,
}; };
}
// ==================== 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 deleteContact = async (contactId: string) => {
const { data, isLoading, error, refetch } = usePartnerContacts(partnerId); if (!partnerId) throw new Error('Partner ID required');
const mutations = usePartnerContactMutations(partnerId || ''); 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 { return {
contacts: data || [], contacts,
isLoading, isLoading,
error, error,
refetch, refresh: fetchContacts,
...mutations, createContact,
// Disable mutations if no partnerId updateContact,
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); }, deleteContact,
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); }, setPrimaryContact,
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
setPrimary: partnerId ? mutations.setPrimary : async () => { throw new Error('Partner ID required'); },
}; };
} }

View File

@ -3,7 +3,7 @@ import type {
ProductCategory, ProductCategory,
CreateCategoryDto, CreateCategoryDto,
UpdateCategoryDto, UpdateCategoryDto,
CategorySearchParams, CategoryFilters,
CategoriesResponse, CategoriesResponse,
CategoryTreeNode, CategoryTreeNode,
} from '../types'; } from '../types';
@ -11,86 +11,68 @@ import type {
const CATEGORIES_URL = '/api/v1/products/categories'; const CATEGORIES_URL = '/api/v1/products/categories';
export const categoriesApi = { export const categoriesApi = {
// Get all categories with filters getAll: async (filters?: CategoryFilters): Promise<CategoriesResponse> => {
getAll: async (params?: CategorySearchParams): Promise<CategoriesResponse> => { const params = new URLSearchParams();
const searchParams = new URLSearchParams(); if (filters?.search) params.append('search', filters.search);
if (params?.search) searchParams.append('search', params.search); if (filters?.parentId) params.append('parentId', filters.parentId);
if (params?.parentId) searchParams.append('parentId', params.parentId); if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
if (params?.isActive !== undefined) searchParams.append('isActive', String(params.isActive)); if (filters?.page) params.append('page', String(filters.page));
if (params?.limit) searchParams.append('limit', String(params.limit)); if (filters?.limit) params.append('limit', String(filters.limit));
if (params?.offset) searchParams.append('offset', String(params.offset)); 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 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<CategoriesResponse>(url);
return { return {
data: response.data.data || [], data: response.data.data || [],
total: response.data.total || 0, meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 },
limit: response.data.limit || 50,
offset: response.data.offset || 0,
}; };
}, },
// Get category by ID
getById: async (id: string): Promise<ProductCategory> => { getById: async (id: string): Promise<ProductCategory> => {
const response = await api.get<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`); const response = await api.get<ProductCategory>(`${CATEGORIES_URL}/${id}`);
if (!response.data.data) { return response.data;
throw new Error('Categoria no encontrada');
}
return response.data.data;
}, },
// Create category
create: async (data: CreateCategoryDto): Promise<ProductCategory> => { create: async (data: CreateCategoryDto): Promise<ProductCategory> => {
const response = await api.post<{ success: boolean; data: ProductCategory }>(CATEGORIES_URL, data); const response = await api.post<ProductCategory>(CATEGORIES_URL, data);
if (!response.data.data) { return response.data;
throw new Error('Error al crear categoria');
}
return response.data.data;
}, },
// Update category
update: async (id: string, data: UpdateCategoryDto): Promise<ProductCategory> => { update: async (id: string, data: UpdateCategoryDto): Promise<ProductCategory> => {
const response = await api.patch<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`, data); const response = await api.patch<ProductCategory>(`${CATEGORIES_URL}/${id}`, data);
if (!response.data.data) { return response.data;
throw new Error('Error al actualizar categoria');
}
return response.data.data;
}, },
// Delete category
delete: async (id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
await api.delete(`${CATEGORIES_URL}/${id}`); await api.delete(`${CATEGORIES_URL}/${id}`);
}, },
// Get root categories (no parent)
getRoots: async (): Promise<ProductCategory[]> => { getRoots: async (): Promise<ProductCategory[]> => {
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>( const response = await api.get<CategoriesResponse>(`${CATEGORIES_URL}?parentId=`);
`${CATEGORIES_URL}?parentId=`
);
return response.data.data || []; return response.data.data || [];
}, },
// Get children of a category
getChildren: async (parentId: string): Promise<ProductCategory[]> => { getChildren: async (parentId: string): Promise<ProductCategory[]> => {
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>( const response = await api.get<CategoriesResponse>(`${CATEGORIES_URL}?parentId=${parentId}`);
`${CATEGORIES_URL}?parentId=${parentId}`
);
return response.data.data || []; return response.data.data || [];
}, },
// Build category tree from flat list getTree: async (): Promise<CategoryTreeNode[]> => {
const response = await categoriesApi.getAll({ limit: 1000 });
return categoriesApi.buildTree(response.data);
},
buildTree: (categories: ProductCategory[]): CategoryTreeNode[] => { buildTree: (categories: ProductCategory[]): CategoryTreeNode[] => {
const map = new Map<string, CategoryTreeNode>(); const map = new Map<string, CategoryTreeNode>();
const roots: CategoryTreeNode[] = []; const roots: CategoryTreeNode[] = [];
// Create nodes
categories.forEach((cat) => { categories.forEach((cat) => {
map.set(cat.id, { ...cat, children: [] }); map.set(cat.id, { ...cat, children: [] });
}); });
// Build tree
categories.forEach((cat) => { categories.forEach((cat) => {
const node = map.get(cat.id); const node = map.get(cat.id);
if (!node) return; if (!node) return;
@ -103,7 +85,6 @@ export const categoriesApi = {
} }
}); });
// Sort by sortOrder
const sortNodes = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => { const sortNodes = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder); nodes.sort((a, b) => a.sortOrder - b.sortOrder);
nodes.forEach((node) => { nodes.forEach((node) => {
@ -116,4 +97,14 @@ export const categoriesApi = {
return sortNodes(roots); return sortNodes(roots);
}, },
activate: async (id: string): Promise<ProductCategory> => {
const response = await api.patch<ProductCategory>(`${CATEGORIES_URL}/${id}`, { isActive: true });
return response.data;
},
deactivate: async (id: string): Promise<ProductCategory> => {
const response = await api.patch<ProductCategory>(`${CATEGORIES_URL}/${id}`, { isActive: false });
return response.data;
},
}; };

View File

@ -0,0 +1,3 @@
export { productsApi } from './products.api';
export { categoriesApi } from './categories.api';
export { pricingApi } from './pricing.api';

View File

@ -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<PricesResponse> => {
const response = await api.get<PricesResponse>(`${PRODUCTS_URL}/${productId}/prices`);
return {
data: response.data.data || [],
total: response.data.total || 0,
};
},
create: async (productId: string, data: CreatePriceDto): Promise<ProductPrice> => {
const response = await api.post<ProductPrice>(`${PRODUCTS_URL}/${productId}/prices`, data);
return response.data;
},
update: async (productId: string, priceId: string, data: UpdatePriceDto): Promise<ProductPrice> => {
const response = await api.patch<ProductPrice>(`${PRODUCTS_URL}/${productId}/prices/${priceId}`, data);
return response.data;
},
delete: async (productId: string, priceId: string): Promise<void> => {
await api.delete(`${PRODUCTS_URL}/${productId}/prices/${priceId}`);
},
activate: async (productId: string, priceId: string): Promise<ProductPrice> => {
const response = await api.patch<ProductPrice>(
`${PRODUCTS_URL}/${productId}/prices/${priceId}`,
{ isActive: true }
);
return response.data;
},
deactivate: async (productId: string, priceId: string): Promise<ProductPrice> => {
const response = await api.patch<ProductPrice>(
`${PRODUCTS_URL}/${productId}/prices/${priceId}`,
{ isActive: false }
);
return response.data;
},
getActivePrice: async (productId: string, priceType: string = 'standard'): Promise<ProductPrice | null> => {
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;
},
};

View File

@ -3,110 +3,129 @@ import type {
Product, Product,
CreateProductDto, CreateProductDto,
UpdateProductDto, UpdateProductDto,
ProductSearchParams, ProductFilters,
ProductsResponse, ProductsResponse,
ProductVariant,
CreateVariantDto,
UpdateVariantDto,
VariantsResponse,
ProductAttribute,
AttributesResponse,
} from '../types'; } from '../types';
const PRODUCTS_URL = '/api/v1/products'; const PRODUCTS_URL = '/api/v1/products';
export const productsApi = { export const productsApi = {
// Get all products with filters // ==================== Products CRUD ====================
getAll: async (params?: ProductSearchParams): Promise<ProductsResponse> => {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.append('search', params.search);
if (params?.categoryId) searchParams.append('categoryId', params.categoryId);
if (params?.productType) searchParams.append('productType', params.productType);
if (params?.isActive !== undefined) searchParams.append('isActive', String(params.isActive));
if (params?.isSellable !== undefined) searchParams.append('isSellable', String(params.isSellable));
if (params?.isPurchasable !== undefined) searchParams.append('isPurchasable', String(params.isPurchasable));
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
const queryString = searchParams.toString(); getAll: async (filters?: ProductFilters): Promise<ProductsResponse> => {
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 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<ProductsResponse>(url);
return { return {
data: response.data.data || [], data: response.data.data || [],
total: response.data.total || 0, meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 },
limit: response.data.limit || 50,
offset: response.data.offset || 0,
}; };
}, },
// Get product by ID
getById: async (id: string): Promise<Product> => { getById: async (id: string): Promise<Product> => {
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`); const response = await api.get<Product>(`${PRODUCTS_URL}/${id}`);
if (!response.data.data) { return response.data;
throw new Error('Producto no encontrado');
}
return response.data.data;
}, },
// Get product by SKU
getBySku: async (sku: string): Promise<Product> => { getBySku: async (sku: string): Promise<Product> => {
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/sku/${sku}`); const response = await api.get<Product>(`${PRODUCTS_URL}/sku/${sku}`);
if (!response.data.data) { return response.data;
throw new Error('Producto no encontrado');
}
return response.data.data;
}, },
// Get product by barcode
getByBarcode: async (barcode: string): Promise<Product> => { getByBarcode: async (barcode: string): Promise<Product> => {
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/barcode/${barcode}`); const response = await api.get<Product>(`${PRODUCTS_URL}/barcode/${barcode}`);
if (!response.data.data) { return response.data;
throw new Error('Producto no encontrado');
}
return response.data.data;
}, },
// Create product
create: async (data: CreateProductDto): Promise<Product> => { create: async (data: CreateProductDto): Promise<Product> => {
const response = await api.post<{ success: boolean; data: Product }>(PRODUCTS_URL, data); const response = await api.post<Product>(PRODUCTS_URL, data);
if (!response.data.data) { return response.data;
throw new Error('Error al crear producto');
}
return response.data.data;
}, },
// Update product
update: async (id: string, data: UpdateProductDto): Promise<Product> => { update: async (id: string, data: UpdateProductDto): Promise<Product> => {
const response = await api.patch<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`, data); const response = await api.patch<Product>(`${PRODUCTS_URL}/${id}`, data);
if (!response.data.data) { return response.data;
throw new Error('Error al actualizar producto');
}
return response.data.data;
}, },
// Delete product
delete: async (id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
await api.delete(`${PRODUCTS_URL}/${id}`); await api.delete(`${PRODUCTS_URL}/${id}`);
}, },
// Get sellable products // ==================== Product Variants ====================
getSellable: async (limit = 50, offset = 0): Promise<ProductsResponse> => {
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>( getVariants: async (productId: string): Promise<VariantsResponse> => {
`${PRODUCTS_URL}/sellable?limit=${limit}&offset=${offset}` const response = await api.get<VariantsResponse>(`${PRODUCTS_URL}/${productId}/variants`);
); return response.data;
return {
data: response.data.data || [],
total: response.data.total || 0,
limit: response.data.limit || limit,
offset: response.data.offset || offset,
};
}, },
// Get purchasable products createVariant: async (productId: string, data: CreateVariantDto): Promise<ProductVariant> => {
getPurchasable: async (limit = 50, offset = 0): Promise<ProductsResponse> => { const response = await api.post<ProductVariant>(`${PRODUCTS_URL}/${productId}/variants`, data);
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>( return response.data;
`${PRODUCTS_URL}/purchasable?limit=${limit}&offset=${offset}` },
);
return { updateVariant: async (productId: string, variantId: string, data: UpdateVariantDto): Promise<ProductVariant> => {
data: response.data.data || [], const response = await api.patch<ProductVariant>(`${PRODUCTS_URL}/${productId}/variants/${variantId}`, data);
total: response.data.total || 0, return response.data;
limit: response.data.limit || limit, },
offset: response.data.offset || offset,
}; deleteVariant: async (productId: string, variantId: string): Promise<void> => {
await api.delete(`${PRODUCTS_URL}/${productId}/variants/${variantId}`);
},
// ==================== Product Attributes ====================
getAttributes: async (productId: string): Promise<AttributesResponse> => {
const response = await api.get<AttributesResponse>(`${PRODUCTS_URL}/${productId}/attributes`);
return response.data;
},
addAttribute: async (productId: string, attributeId: string): Promise<ProductAttribute> => {
const response = await api.post<ProductAttribute>(`${PRODUCTS_URL}/${productId}/attributes`, { attributeId });
return response.data;
},
removeAttribute: async (productId: string, attributeId: string): Promise<void> => {
await api.delete(`${PRODUCTS_URL}/${productId}/attributes/${attributeId}`);
},
// ==================== Utility Methods ====================
activate: async (id: string): Promise<Product> => {
const response = await api.patch<Product>(`${PRODUCTS_URL}/${id}`, { isActive: true });
return response.data;
},
deactivate: async (id: string): Promise<Product> => {
const response = await api.patch<Product>(`${PRODUCTS_URL}/${id}`, { isActive: false });
return response.data;
},
getSellable: async (filters?: ProductFilters): Promise<ProductsResponse> => {
return productsApi.getAll({ ...filters, isSellable: true });
},
getPurchasable: async (filters?: ProductFilters): Promise<ProductsResponse> => {
return productsApi.getAll({ ...filters, isPurchasable: true });
}, },
}; };

View File

@ -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<AttributeDisplayType, React.ReactNode> = {
radio: <span className="text-xs">O</span>,
select: <span className="text-xs">V</span>,
color: <Palette className="h-3 w-3" />,
pills: <span className="text-xs">[]</span>,
};
const displayTypeLabels: Record<AttributeDisplayType, string> = {
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<ProductAttribute | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleViewAttribute = (attribute: ProductAttribute) => {
setSelectedAttribute(attribute);
setIsModalOpen(true);
};
const renderColorSwatch = (color: string | null) => {
if (!color) return null;
return (
<span
className="inline-block h-4 w-4 rounded-full border border-gray-200"
style={{ backgroundColor: color }}
/>
);
};
const renderValue = (attribute: ProductAttribute, value: ProductAttributeValue) => {
return (
<div
key={value.id}
className="flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-2"
>
{attribute.displayType === 'color' && renderColorSwatch(value.htmlColor)}
{value.imageUrl && <ImageIcon className="h-4 w-4 text-gray-400" />}
<span className="text-sm">{value.name}</span>
{value.code && (
<span className="text-xs text-gray-400">({value.code})</span>
)}
{editable && onRemoveValue && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveValue(attribute.id, value.id);
}}
className="ml-auto text-gray-400 hover:text-red-500"
disabled={isLoading}
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
};
if (attributes.length === 0) {
return (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<Palette className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-medium text-gray-900">Sin atributos</h3>
<p className="mt-1 text-sm text-gray-500">
Agrega atributos como color, talla o material para crear variantes.
</p>
{editable && onAddAttribute && (
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={onAddAttribute}
disabled={isLoading}
>
<Plus className="mr-2 h-4 w-4" />
Agregar atributo
</Button>
)}
</div>
);
}
return (
<div className="space-y-4">
{/* Attributes list */}
<div className="space-y-3">
{attributes.map((attribute) => (
<div
key={attribute.id}
className="rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary-100 text-primary-600">
{displayTypeIcons[attribute.displayType]}
</div>
<div>
<h4 className="font-medium text-gray-900">{attribute.name}</h4>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{attribute.code}</span>
<span>-</span>
<span>{displayTypeLabels[attribute.displayType]}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={attribute.isActive ? 'success' : 'default'} size="sm">
{attribute.isActive ? 'Activo' : 'Inactivo'}
</Badge>
{editable && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewAttribute(attribute)}
>
Ver valores
</Button>
{onRemoveAttribute && (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => onRemoveAttribute(attribute.id)}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
</div>
{/* Preview of values */}
{attribute.values && attribute.values.length > 0 && (
<div className="flex flex-wrap gap-2">
{attribute.values.slice(0, 6).map((value) => (
<span
key={value.id}
className="inline-flex items-center gap-1.5 rounded-md bg-white px-2 py-1 text-xs border border-gray-200"
>
{attribute.displayType === 'color' && renderColorSwatch(value.htmlColor)}
{value.name}
</span>
))}
{attribute.values.length > 6 && (
<span className="inline-flex items-center rounded-md bg-gray-200 px-2 py-1 text-xs text-gray-600">
+{attribute.values.length - 6} mas
</span>
)}
</div>
)}
</div>
))}
</div>
{/* Add attribute button */}
{editable && onAddAttribute && (
<Button
variant="outline"
className="w-full"
onClick={onAddAttribute}
disabled={isLoading}
>
<Plus className="mr-2 h-4 w-4" />
Agregar atributo
</Button>
)}
{/* Attribute values modal */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={selectedAttribute ? `Valores de: ${selectedAttribute.name}` : 'Valores'}
size="lg"
>
<ModalContent>
{selectedAttribute && (
<div className="space-y-4">
<div className="flex items-center gap-4 rounded-lg bg-gray-50 p-3">
<div>
<span className="text-sm text-gray-500">Codigo:</span>
<span className="ml-2 font-mono text-sm">{selectedAttribute.code}</span>
</div>
<div>
<span className="text-sm text-gray-500">Tipo:</span>
<span className="ml-2 text-sm">{displayTypeLabels[selectedAttribute.displayType]}</span>
</div>
</div>
{selectedAttribute.description && (
<p className="text-sm text-gray-600">{selectedAttribute.description}</p>
)}
<div className="space-y-2">
<h5 className="text-sm font-medium text-gray-700">
Valores ({selectedAttribute.values?.length || 0})
</h5>
{selectedAttribute.values && selectedAttribute.values.length > 0 ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{selectedAttribute.values.map((value) => renderValue(selectedAttribute, value))}
</div>
) : (
<p className="text-sm text-gray-500">No hay valores definidos</p>
)}
</div>
{editable && onAddValue && (
<Button
variant="outline"
size="sm"
onClick={() => onAddValue(selectedAttribute.id)}
disabled={isLoading}
>
<Plus className="mr-2 h-4 w-4" />
Agregar valor
</Button>
)}
</div>
)}
</ModalContent>
<ModalFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
Cerrar
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@ -6,7 +6,7 @@ import { Button } from '@components/atoms/Button';
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
import { DataTable, type Column } from '@components/organisms/DataTable'; import { DataTable, type Column } from '@components/organisms/DataTable';
import { ConfirmModal } from '@components/organisms/Modal'; import { ConfirmModal } from '@components/organisms/Modal';
import { useProductPrices, useProductPriceMutations, usePricingHelpers } from '../hooks'; import { useProductPrices, usePricingHelpers } from '../hooks';
import type { ProductPrice, PriceType } from '../types'; import type { ProductPrice, PriceType } from '../types';
const priceTypeConfig: Record<PriceType, { label: string; color: 'info' | 'success' | 'warning' | 'primary' }> = { const priceTypeConfig: Record<PriceType, { label: string; color: 'info' | 'success' | 'warning' | 'primary' }> = {
@ -35,20 +35,24 @@ export function PricingTable({
onEditPrice, onEditPrice,
className, className,
}: PricingTableProps) { }: PricingTableProps) {
const { data, isLoading, error, refetch } = useProductPrices(productId); const {
const { delete: deletePrice, isDeleting } = useProductPriceMutations(); prices,
isLoading,
error,
deletePrice
} = useProductPrices(productId);
const { formatPrice, calculateMargin } = usePricingHelpers(); const { formatPrice, calculateMargin } = usePricingHelpers();
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [priceToDelete, setPriceToDelete] = useState<ProductPrice | null>(null); const [priceToDelete, setPriceToDelete] = useState<ProductPrice | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const prices = data?.data || [];
const handleDelete = async () => { const handleDelete = async () => {
if (!priceToDelete) return; if (!priceToDelete) return;
setIsDeleting(true);
try { try {
await deletePrice(priceToDelete.id); await deletePrice(priceToDelete.id);
refetch();
} finally { } finally {
setIsDeleting(false);
setDeleteModalOpen(false); setDeleteModalOpen(false);
setPriceToDelete(null); setPriceToDelete(null);
} }

View File

@ -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<string>;
onSelect?: (id: string) => void;
onSelectAll?: () => void;
}
const productTypeColors: Record<string, 'default' | 'primary' | 'info' | 'success' | 'warning' | 'danger'> = {
product: 'primary',
service: 'info',
consumable: 'warning',
kit: 'success',
};
const productTypeLabels: Record<string, string> = {
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: <Eye className="h-4 w-4" />,
onClick: () => onView(product),
});
}
if (onEdit) {
items.push({
key: 'edit',
label: 'Editar',
icon: <Edit className="h-4 w-4" />,
onClick: () => onEdit(product),
});
}
if (onDuplicate) {
items.push({
key: 'duplicate',
label: 'Duplicar',
icon: <Copy className="h-4 w-4" />,
onClick: () => onDuplicate(product),
});
}
if (product.isActive && onDeactivate) {
items.push({
key: 'deactivate',
label: 'Desactivar',
icon: <PowerOff className="h-4 w-4" />,
onClick: () => onDeactivate(product),
});
}
if (!product.isActive && onActivate) {
items.push({
key: 'activate',
label: 'Activar',
icon: <Power className="h-4 w-4" />,
onClick: () => onActivate(product),
});
}
if (onDelete) {
items.push({
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => onDelete(product),
});
}
return items;
};
const columns = useMemo<Column<Product>[]>(() => [
{
key: 'sku',
header: 'SKU',
accessor: 'sku',
sortable: true,
width: '120px',
render: (product) => (
<span className="font-mono text-sm">{product.sku}</span>
),
},
{
key: 'name',
header: 'Producto',
sortable: true,
render: (product) => (
<div className="flex items-center gap-3">
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.name}
className="h-10 w-10 rounded-lg object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-400">
<span className="text-xs">{product.name.charAt(0).toUpperCase()}</span>
</div>
)}
<div>
<div className="font-medium text-gray-900">{product.name}</div>
{product.category && (
<div className="text-xs text-gray-500">{product.category.name}</div>
)}
</div>
</div>
),
},
{
key: 'productType',
header: 'Tipo',
sortable: true,
width: '120px',
render: (product) => (
<Badge variant={productTypeColors[product.productType] || 'default'}>
{productTypeLabels[product.productType] || product.productType}
</Badge>
),
},
{
key: 'price',
header: 'Precio',
sortable: true,
align: 'right',
width: '130px',
render: (product) => (
<span className="font-medium">{formatPrice(product.price, product.currency)}</span>
),
},
{
key: 'cost',
header: 'Costo',
sortable: true,
align: 'right',
width: '130px',
render: (product) => (
<span className="text-gray-600">{formatPrice(product.cost, product.currency)}</span>
),
},
{
key: 'isActive',
header: 'Estado',
sortable: true,
width: '100px',
render: (product) => (
<Badge variant={product.isActive ? 'success' : 'default'}>
{product.isActive ? 'Activo' : 'Inactivo'}
</Badge>
),
},
{
key: 'actions',
header: '',
width: '60px',
align: 'center',
render: (product) => (
<div onClick={(e) => e.stopPropagation()}>
<Dropdown
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
}
items={getDropdownItems(product)}
align="right"
/>
</div>
),
},
], [formatPrice, onView, onEdit, onDelete, onDuplicate, onActivate, onDeactivate]);
return (
<DataTable
data={products}
columns={columns}
isLoading={isLoading}
emptyMessage="No se encontraron productos"
pagination={onPageChange ? {
page,
limit,
total,
totalPages,
onPageChange,
onLimitChange,
} : undefined}
sorting={onSort ? {
sortBy: sortBy || null,
sortOrder,
onSort,
} : undefined}
selection={selectedIds && onSelect && onSelectAll ? {
selectedIds,
onSelect,
onSelectAll,
getRowId: (product) => product.id,
} : undefined}
onRowClick={onView}
rowClassName={(product) => !product.isActive ? 'bg-gray-50 opacity-75' : ''}
/>
);
}

View File

@ -4,6 +4,9 @@ export type { ProductFormProps } from './ProductForm';
export { ProductCard } from './ProductCard'; export { ProductCard } from './ProductCard';
export type { ProductCardProps } from './ProductCard'; export type { ProductCardProps } from './ProductCard';
export { ProductTable } from './ProductTable';
export type { ProductTableProps } from './ProductTable';
export { CategoryTree } from './CategoryTree'; export { CategoryTree } from './CategoryTree';
export type { CategoryTreeProps } from './CategoryTree'; export type { CategoryTreeProps } from './CategoryTree';
@ -12,3 +15,6 @@ export type { VariantSelectorProps } from './VariantSelector';
export { PricingTable } from './PricingTable'; export { PricingTable } from './PricingTable';
export type { PricingTableProps } from './PricingTable'; export type { PricingTableProps } from './PricingTable';
export { AttributeEditor } from './AttributeEditor';
export type { AttributeEditorProps } from './AttributeEditor';

View File

@ -3,10 +3,6 @@ export {
useProducts, useProducts,
useProduct, useProduct,
useProductBySku, useProductBySku,
useProductByBarcode,
useSellableProducts,
usePurchasableProducts,
useProductMutations,
useProductSearch, useProductSearch,
} from './useProducts'; } from './useProducts';
export type { UseProductsOptions } from './useProducts'; export type { UseProductsOptions } from './useProducts';
@ -18,7 +14,6 @@ export {
useCategoryTree, useCategoryTree,
useRootCategories, useRootCategories,
useChildCategories, useChildCategories,
useCategoryMutations,
useCategoryOptions, useCategoryOptions,
} from './useCategories'; } from './useCategories';
export type { UseCategoriesOptions } from './useCategories'; export type { UseCategoriesOptions } from './useCategories';
@ -27,6 +22,11 @@ export type { UseCategoriesOptions } from './useCategories';
export { export {
useProductPrices, useProductPrices,
useProductPrice, useProductPrice,
useProductPriceMutations,
usePricingHelpers, usePricingHelpers,
} from './useProductPricing'; } from './useProductPricing';
// Variants Hooks
export {
useProductVariants,
useProductVariant,
} from './useProductVariants';

View File

@ -1,5 +1,4 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { categoriesApi } from '../api/categories.api'; import { categoriesApi } from '../api/categories.api';
import type { import type {
ProductCategory, ProductCategory,
@ -9,134 +8,250 @@ import type {
CategoryTreeNode, CategoryTreeNode,
} from '../types'; } from '../types';
const QUERY_KEY = 'product-categories';
export interface UseCategoriesOptions extends CategorySearchParams { export interface UseCategoriesOptions extends CategorySearchParams {
enabled?: boolean; autoFetch?: boolean;
} }
// ==================== Categories List Hook ==================== // ==================== Categories List Hook ====================
export function useCategories(options: UseCategoriesOptions = {}) { export function useCategories(options: UseCategoriesOptions = {}) {
const { enabled = true, ...params } = options; const { autoFetch = true, ...params } = options;
const [categories, setCategories] = useState<ProductCategory[]>([]);
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<string | null>(null);
return useQuery({ const fetchCategories = useCallback(async () => {
queryKey: [QUERY_KEY, 'list', params], setIsLoading(true);
queryFn: () => categoriesApi.getAll(params), setError(null);
enabled, try {
staleTime: 1000 * 60 * 10, // 10 minutes - categories change less frequently 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 ==================== // ==================== Single Category Hook ====================
export function useCategory(id: string | null | undefined) { export function useCategory(id: string | null | undefined) {
return useQuery({ const [category, setCategory] = useState<ProductCategory | null>(null);
queryKey: [QUERY_KEY, 'detail', id], const [isLoading, setIsLoading] = useState(false);
queryFn: () => categoriesApi.getById(id as string), const [error, setError] = useState<string | null>(null);
enabled: !!id,
staleTime: 1000 * 60 * 10, 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 ==================== // ==================== Category Tree Hook ====================
export function useCategoryTree() { export function useCategoryTree() {
const { data, isLoading, error, refetch } = useQuery({ const [allCategories, setAllCategories] = useState<ProductCategory[]>([]);
queryKey: [QUERY_KEY, 'all'], const [isLoading, setIsLoading] = useState(false);
queryFn: () => categoriesApi.getAll({ limit: 1000 }), // Get all for tree const [error, setError] = useState<string | null>(null);
staleTime: 1000 * 60 * 10,
}); 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<CategoryTreeNode[]>(() => { const tree = useMemo<CategoryTreeNode[]>(() => {
if (!data?.data) return []; if (allCategories.length === 0) return [];
return categoriesApi.buildTree(data.data); return categoriesApi.buildTree(allCategories);
}, [data?.data]); }, [allCategories]);
return { return {
tree, tree,
categories: data?.data || [], categories: allCategories,
isLoading, isLoading,
error, error,
refetch, refresh: fetchAllCategories,
}; };
} }
// ==================== Root Categories Hook ==================== // ==================== Root Categories Hook ====================
export function useRootCategories() { export function useRootCategories() {
return useQuery({ const [categories, setCategories] = useState<ProductCategory[]>([]);
queryKey: [QUERY_KEY, 'roots'], const [isLoading, setIsLoading] = useState(false);
queryFn: () => categoriesApi.getRoots(), const [error, setError] = useState<string | null>(null);
staleTime: 1000 * 60 * 10,
}); 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 ==================== // ==================== Child Categories Hook ====================
export function useChildCategories(parentId: string | null | undefined) { export function useChildCategories(parentId: string | null | undefined) {
return useQuery({ const [categories, setCategories] = useState<ProductCategory[]>([]);
queryKey: [QUERY_KEY, 'children', parentId], const [isLoading, setIsLoading] = useState(false);
queryFn: () => categoriesApi.getChildren(parentId as string), const [error, setError] = useState<string | null>(null);
enabled: !!parentId,
staleTime: 1000 * 60 * 10,
});
}
// ==================== 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() { useEffect(() => {
const queryClient = useQueryClient(); if (parentId) {
fetchChildren();
const createMutation = useMutation({ }
mutationFn: (data: CreateCategoryDto) => categoriesApi.create(data), }, [fetchChildren, parentId]);
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateCategoryDto }) =>
categoriesApi.update(id, data),
onSuccess: (_: unknown, variables: { id: string; data: UpdateCategoryDto }) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => categoriesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return { return {
create: createMutation.mutateAsync, categories,
update: updateMutation.mutateAsync, isLoading,
delete: deleteMutation.mutateAsync, error,
isCreating: createMutation.isPending, refresh: fetchChildren,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
}; };
} }
// ==================== Category Options Hook (for selects) ==================== // ==================== Category Options Hook (for selects) ====================
export function useCategoryOptions() { export function useCategoryOptions() {
const { data, isLoading } = useCategories({ isActive: true, limit: 500 }); const { categories, isLoading } = useCategories({ isActive: true, limit: 500 });
const options = useMemo(() => { const options = useMemo(() => {
if (!data?.data) return []; return categories.map((cat: ProductCategory) => ({
return data.data.map((cat: ProductCategory) => ({
value: cat.id, value: cat.id,
label: cat.hierarchyPath ? `${cat.hierarchyPath} / ${cat.name}` : cat.name, label: cat.hierarchyPath ? `${cat.hierarchyPath} / ${cat.name}` : cat.name,
category: cat, category: cat,
})); }));
}, [data?.data]); }, [categories]);
return { options, isLoading }; return { options, isLoading };
} }

View File

@ -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 { 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 PRICES_URL = '/api/v1/products/prices';
const QUERY_KEY = 'product-prices';
// ==================== API Functions ====================
const pricesApi = {
getByProduct: async (productId: string): Promise<PricesResponse> => {
const response = await api.get<{ success: boolean; data: ProductPrice[]; total: number }>(
`${PRICES_URL}?productId=${productId}`
);
return {
data: response.data.data || [],
total: response.data.total || 0,
};
},
getById: async (id: string): Promise<ProductPrice> => {
const response = await api.get<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`);
if (!response.data.data) {
throw new Error('Precio no encontrado');
}
return response.data.data;
},
create: async (data: CreatePriceDto): Promise<ProductPrice> => {
const response = await api.post<{ success: boolean; data: ProductPrice }>(PRICES_URL, data);
if (!response.data.data) {
throw new Error('Error al crear precio');
}
return response.data.data;
},
update: async (id: string, data: UpdatePriceDto): Promise<ProductPrice> => {
const response = await api.patch<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`, data);
if (!response.data.data) {
throw new Error('Error al actualizar precio');
}
return response.data.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${PRICES_URL}/${id}`);
},
};
// ==================== Product Prices Hook ==================== // ==================== Product Prices Hook ====================
export function useProductPrices(productId: string | null | undefined) { export function useProductPrices(productId: string | null | undefined) {
return useQuery({ const [prices, setPrices] = useState<ProductPrice[]>([]);
queryKey: [QUERY_KEY, 'byProduct', productId], const [total, setTotal] = useState(0);
queryFn: () => pricesApi.getByProduct(productId as string), const [isLoading, setIsLoading] = useState(false);
enabled: !!productId, const [error, setError] = useState<string | null>(null);
staleTime: 1000 * 60 * 5,
}); 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 ==================== // ==================== Single Price Hook ====================
export function useProductPrice(id: string | null | undefined) { export function useProductPrice(id: string | null | undefined) {
return useQuery({ const [price, setPrice] = useState<ProductPrice | null>(null);
queryKey: [QUERY_KEY, 'detail', id], const [isLoading, setIsLoading] = useState(false);
queryFn: () => pricesApi.getById(id as string), const [error, setError] = useState<string | null>(null);
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
// ==================== 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() { useEffect(() => {
const queryClient = useQueryClient(); if (id) {
fetchPrice();
const createMutation = useMutation({ }
mutationFn: (data: CreatePriceDto) => pricesApi.create(data), }, [fetchPrice, id]);
onSuccess: (_: unknown, variables: CreatePriceDto) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'byProduct', variables.productId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePriceDto }) => pricesApi.update(id, data),
onSuccess: (_: unknown, variables: { id: string; data: UpdatePriceDto }) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => pricesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return { return {
create: createMutation.mutateAsync, price,
update: updateMutation.mutateAsync, isLoading,
delete: deleteMutation.mutateAsync, error,
isCreating: createMutation.isPending, refresh: fetchPrice,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
}; };
} }

View File

@ -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<ProductVariant[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<ProductVariant | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View File

@ -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 { productsApi } from '../api/products.api';
import type { import type {
Product,
CreateProductDto, CreateProductDto,
UpdateProductDto, UpdateProductDto,
ProductSearchParams, ProductSearchParams,
} from '../types'; } from '../types';
const QUERY_KEY = 'products';
export interface UseProductsOptions extends ProductSearchParams { export interface UseProductsOptions extends ProductSearchParams {
enabled?: boolean; autoFetch?: boolean;
} }
// ==================== Products List Hook ==================== // ==================== Products List Hook ====================
export function useProducts(options: UseProductsOptions = {}) { export function useProducts(options: UseProductsOptions = {}) {
const { enabled = true, ...params } = options; const { autoFetch = true, ...params } = options;
const [products, setProducts] = useState<Product[]>([]);
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<string | null>(null);
return useQuery({ const fetchProducts = useCallback(async () => {
queryKey: [QUERY_KEY, 'list', params], setIsLoading(true);
queryFn: () => productsApi.getAll(params), setError(null);
enabled, try {
staleTime: 1000 * 60 * 5, // 5 minutes 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 ==================== // ==================== Single Product Hook ====================
export function useProduct(id: string | null | undefined) { export function useProduct(id: string | null | undefined) {
return useQuery({ const [product, setProduct] = useState<Product | null>(null);
queryKey: [QUERY_KEY, 'detail', id], const [isLoading, setIsLoading] = useState(false);
queryFn: () => productsApi.getById(id as string), const [error, setError] = useState<string | null>(null);
enabled: !!id,
staleTime: 1000 * 60 * 5, 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 ==================== // ==================== Product by SKU Hook ====================
export function useProductBySku(sku: string | null | undefined) { export function useProductBySku(sku: string | null | undefined) {
return useQuery({ const [product, setProduct] = useState<Product | null>(null);
queryKey: [QUERY_KEY, 'bySku', sku], const [isLoading, setIsLoading] = useState(false);
queryFn: () => productsApi.getBySku(sku as string), const [error, setError] = useState<string | null>(null);
enabled: !!sku,
staleTime: 1000 * 60 * 5,
});
}
// ==================== 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) { useEffect(() => {
return useQuery({ if (sku) {
queryKey: [QUERY_KEY, 'byBarcode', barcode], fetchProduct();
queryFn: () => productsApi.getByBarcode(barcode as string), }
enabled: !!barcode, }, [fetchProduct, sku]);
staleTime: 1000 * 60 * 5,
});
}
// ==================== Sellable Products Hook ====================
export function useSellableProducts(limit = 50, offset = 0) {
return useQuery({
queryKey: [QUERY_KEY, 'sellable', limit, offset],
queryFn: () => productsApi.getSellable(limit, offset),
staleTime: 1000 * 60 * 5,
});
}
// ==================== Purchasable Products Hook ====================
export function usePurchasableProducts(limit = 50, offset = 0) {
return useQuery({
queryKey: [QUERY_KEY, 'purchasable', limit, offset],
queryFn: () => productsApi.getPurchasable(limit, offset),
staleTime: 1000 * 60 * 5,
});
}
// ==================== Product Mutations Hook ====================
export function useProductMutations() {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: CreateProductDto) => productsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
productsApi.update(id, data),
onSuccess: (_: unknown, variables: { id: string; data: UpdateProductDto }) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => productsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
return { return {
create: createMutation.mutateAsync, product,
update: updateMutation.mutateAsync, isLoading,
delete: deleteMutation.mutateAsync, error,
isCreating: createMutation.isPending, refresh: fetchProduct,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
}; };
} }
@ -123,11 +173,34 @@ export function useProductMutations() {
export function useProductSearch(searchTerm: string, options?: { limit?: number }) { export function useProductSearch(searchTerm: string, options?: { limit?: number }) {
const { limit = 10 } = options || {}; const { limit = 10 } = options || {};
const [results, setResults] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
return useQuery({ const search = useCallback(async () => {
queryKey: [QUERY_KEY, 'search', searchTerm, limit], if (searchTerm.length < 2) {
queryFn: () => productsApi.getAll({ search: searchTerm, limit }), setResults([]);
enabled: searchTerm.length >= 2, return;
staleTime: 1000 * 30, // 30 seconds for search results }
}); 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,
};
} }

View File

@ -1,6 +1,7 @@
// API // API
export { productsApi } from './api/products.api'; export { productsApi } from './api/products.api';
export { categoriesApi } from './api/categories.api'; export { categoriesApi } from './api/categories.api';
export { pricingApi } from './api/pricing.api';
// Components // Components
export * from './components'; export * from './components';

View File

@ -10,7 +10,7 @@ import { DataTable, type Column } from '@components/organisms/DataTable';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useCategories, useCategoryMutations, useCategoryOptions } from '../hooks'; import { useCategories, useCategoryOptions } from '../hooks';
import { CategoryTree } from '../components'; import { CategoryTree } from '../components';
import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, CategorySearchParams } from '../types'; import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, CategorySearchParams } from '../types';
@ -28,7 +28,6 @@ export function CategoriesPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filters] = useState<CategorySearchParams>({ const [filters] = useState<CategorySearchParams>({
limit: 50, limit: 50,
offset: 0,
}); });
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [viewMode, setViewMode] = useState<'tree' | 'table'>('tree'); const [viewMode, setViewMode] = useState<'tree' | 'table'>('tree');
@ -41,17 +40,23 @@ export function CategoriesPage() {
const queryParams: CategorySearchParams = { const queryParams: CategorySearchParams = {
...filters, ...filters,
search: searchTerm || undefined, search: searchTerm || undefined,
offset: (page - 1) * (filters.limit || 50), page,
}; };
const { data, isLoading, refetch } = useCategories(queryParams); const {
const { create, update, delete: deleteCategory, isCreating, isUpdating, isDeleting } = useCategoryMutations(); categories,
total,
totalPages,
isLoading,
createCategory,
updateCategory,
deleteCategory
} = useCategories(queryParams);
const { options: categoryOptions } = useCategoryOptions(); const { options: categoryOptions } = useCategoryOptions();
const [isCreating, setIsCreating] = useState(false);
const categories = data?.data || []; const [isUpdating, setIsUpdating] = useState(false);
const total = data?.total || 0; const [isDeleting, setIsDeleting] = useState(false);
const limit = filters.limit || 50; const limit = filters.limit || 50;
const totalPages = Math.ceil(total / limit);
const { const {
register, register,
@ -108,21 +113,34 @@ export function CategoriesPage() {
}; };
if (editingCategory) { 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 { } else {
await create(cleanData as CreateCategoryDto); setIsCreating(true);
try {
await createCategory(cleanData as CreateCategoryDto);
} finally {
setIsCreating(false);
}
} }
setFormModalOpen(false); setFormModalOpen(false);
refetch();
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!categoryToDelete) return; if (!categoryToDelete) return;
await deleteCategory(categoryToDelete.id); setIsDeleting(true);
setDeleteModalOpen(false); try {
setCategoryToDelete(null); await deleteCategory(categoryToDelete.id);
refetch(); } finally {
setIsDeleting(false);
setDeleteModalOpen(false);
setCategoryToDelete(null);
}
}; };
const columns: Column<ProductCategory>[] = [ const columns: Column<ProductCategory>[] = [

View File

@ -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<typeof priceSchema>;
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<PriceType, 'default' | 'primary' | 'info' | 'success' | 'warning'> = {
standard: 'primary',
wholesale: 'info',
retail: 'success',
promo: 'warning',
};
const priceTypeLabels: Record<PriceType, string> = {
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<ProductPrice | null>(null);
const [priceToDelete, setPriceToDelete] = useState<ProductPrice | null>(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<PriceFormData>({
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<ProductPrice>[] = [
{
key: 'priceType',
header: 'Tipo',
width: '120px',
render: (row) => (
<Badge variant={priceTypeColors[row.priceType]}>
{priceTypeLabels[row.priceType]}
</Badge>
),
},
{
key: 'priceListName',
header: 'Lista de precios',
render: (row) => row.priceListName || '-',
},
{
key: 'price',
header: 'Precio',
align: 'right',
render: (row) => (
<span className="font-medium">{formatPrice(row.price, row.currency)}</span>
),
},
{
key: 'minQuantity',
header: 'Cantidad minima',
align: 'center',
render: (row) => row.minQuantity,
},
{
key: 'validFrom',
header: 'Vigencia',
render: (row) => (
<div className="flex items-center gap-1 text-sm text-gray-500">
<Calendar className="h-3 w-3" />
<span>
{row.validFrom ? new Date(row.validFrom).toLocaleDateString('es-MX') : 'Sin inicio'}
{' - '}
{row.validTo ? new Date(row.validTo).toLocaleDateString('es-MX') : 'Sin fin'}
</span>
</div>
),
},
{
key: 'isActive',
header: 'Estado',
width: '100px',
render: (row) => (
<Badge variant={row.isActive ? 'success' : 'default'}>
{row.isActive ? 'Activo' : 'Inactivo'}
</Badge>
),
},
{
key: 'actions',
header: '',
align: 'right',
width: '100px',
render: (row) => (
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditModal(row)}>
Editar
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => openDeleteModal(row)}
>
Eliminar
</Button>
</div>
),
},
];
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Spinner size="lg" />
</div>
);
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/products/${productId}`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Precios: {product?.name || 'Producto'}
</h1>
<p className="mt-1 text-sm text-gray-500">
Gestiona los precios y listas de precios para este producto
</p>
</div>
</div>
<Button onClick={openCreateModal} leftIcon={<Plus className="h-4 w-4" />}>
Nuevo Precio
</Button>
</div>
{/* Summary */}
{product && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-100 p-2">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-500">Precio base</p>
<p className="text-lg font-semibold">
{formatPrice(product.price, product.currency)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-blue-100 p-2">
<DollarSign className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">Costo</p>
<p className="text-lg font-semibold">
{formatPrice(product.cost, product.currency)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-purple-100 p-2">
<DollarSign className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-500">Precios configurados</p>
<p className="text-lg font-semibold">{prices.length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Prices Table */}
<Card>
<CardHeader>
<CardTitle>Lista de Precios</CardTitle>
</CardHeader>
<CardContent className="p-0">
<DataTable
data={prices}
columns={columns}
isLoading={pricesLoading}
emptyMessage="No hay precios configurados"
/>
</CardContent>
</Card>
{/* Form Modal */}
<Modal
isOpen={formModalOpen}
onClose={() => setFormModalOpen(false)}
title={editingPrice ? 'Editar Precio' : 'Nuevo Precio'}
size="lg"
>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Tipo de precio" error={errors.priceType?.message} required>
<select
{...register('priceType')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{priceTypeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
<FormField label="Lista de precios" error={errors.priceListName?.message}>
<input
type="text"
{...register('priceListName')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Ej: Lista Corporativa"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField label="Precio" error={errors.price?.message} required>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
{...register('price')}
className="w-full rounded-md border border-gray-300 py-2 pl-8 pr-3 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="0.00"
min={0}
/>
</div>
</FormField>
<FormField label="Moneda" error={errors.currency?.message}>
<select
{...register('currency')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</FormField>
<FormField label="Cantidad minima" error={errors.minQuantity?.message}>
<input
type="number"
{...register('minQuantity')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="1"
min={1}
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Fecha de inicio" error={errors.validFrom?.message}>
<input
type="date"
{...register('validFrom')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</FormField>
<FormField label="Fecha de fin" error={errors.validTo?.message}>
<input
type="date"
{...register('validTo')}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</FormField>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('isActive')}
className="h-4 w-4 rounded border-gray-300 text-blue-600"
/>
<span className="text-sm">Precio activo</span>
</label>
<div className="flex justify-end gap-3 border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => setFormModalOpen(false)}
>
Cancelar
</Button>
<Button type="submit" isLoading={isCreating || isUpdating}>
{editingPrice ? 'Guardar' : 'Crear'}
</Button>
</div>
</form>
</Modal>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteModalOpen}
onClose={() => {
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}
/>
</div>
);
}

View File

@ -18,9 +18,9 @@ import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs'; import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
import { ConfirmModal } from '@components/organisms/Modal'; import { ConfirmModal } from '@components/organisms/Modal';
import { Spinner } from '@components/atoms/Spinner'; import { Spinner } from '@components/atoms/Spinner';
import { useProduct, useProductMutations, usePricingHelpers } from '../hooks'; import { useProduct, useProducts, usePricingHelpers } from '../hooks';
import { ProductForm, PricingTable } from '../components'; import { ProductForm, PricingTable } from '../components';
import type { Product, ProductType, UpdateProductDto } from '../types'; import type { ProductType, UpdateProductDto } from '../types';
const productTypeLabels: Record<ProductType, string> = { const productTypeLabels: Record<ProductType, string> = {
product: 'Producto', product: 'Producto',
@ -35,26 +35,33 @@ export function ProductDetailPage() {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const { data: product, isLoading, error, refetch } = useProduct(id) as { const { product, isLoading, error, refresh } = useProduct(id);
data: Product | undefined; const { updateProduct, deleteProduct } = useProducts({ autoFetch: false });
isLoading: boolean;
error: Error | null;
refetch: () => void;
};
const { update, delete: deleteProduct, isUpdating, isDeleting } = useProductMutations();
const { formatPrice, calculateMargin } = usePricingHelpers(); const { formatPrice, calculateMargin } = usePricingHelpers();
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleUpdate = async (data: UpdateProductDto) => { const handleUpdate = async (data: UpdateProductDto) => {
if (!id) return; if (!id) return;
await update({ id, data }); setIsUpdating(true);
setIsEditing(false); try {
refetch(); await updateProduct(id, data);
setIsEditing(false);
refresh();
} finally {
setIsUpdating(false);
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!id) return; if (!id) return;
await deleteProduct(id); setIsDeleting(true);
navigate('/products'); try {
await deleteProduct(id);
navigate('/products');
} finally {
setIsDeleting(false);
}
}; };
const handleDuplicate = () => { const handleDuplicate = () => {

View File

@ -10,7 +10,7 @@ import { DataTable, type Column } from '@components/organisms/DataTable';
import { Select } from '@components/organisms/Select'; import { Select } from '@components/organisms/Select';
import { ConfirmModal } from '@components/organisms/Modal'; import { ConfirmModal } from '@components/organisms/Modal';
import { useDebounce } from '@hooks/useDebounce'; import { useDebounce } from '@hooks/useDebounce';
import { useProducts, useProductMutations, useCategoryOptions, usePricingHelpers } from '../hooks'; import { useProducts, useCategoryOptions, usePricingHelpers } from '../hooks';
import { ProductCard } from '../components'; import { ProductCard } from '../components';
import type { Product, ProductType, ProductSearchParams } from '../types'; import type { Product, ProductType, ProductSearchParams } from '../types';
@ -36,7 +36,6 @@ export function ProductsPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<ProductSearchParams>({ const [filters, setFilters] = useState<ProductSearchParams>({
limit: 25, limit: 25,
offset: 0,
}); });
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@ -49,16 +48,20 @@ export function ProductsPage() {
const queryParams: ProductSearchParams = { const queryParams: ProductSearchParams = {
...filters, ...filters,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
offset: (page - 1) * (filters.limit || 25), page,
}; };
const { data, isLoading, error, refetch } = useProducts(queryParams); const {
const { delete: deleteProduct, isDeleting } = useProductMutations(); products,
total,
const products = data?.data || []; totalPages,
const total = data?.total || 0; isLoading,
error,
refresh,
deleteProduct
} = useProducts(queryParams);
const [isDeleting, setIsDeleting] = useState(false);
const limit = filters.limit || 25; const limit = filters.limit || 25;
const totalPages = Math.ceil(total / limit);
const handlePageChange = useCallback((newPage: number) => { const handlePageChange = useCallback((newPage: number) => {
setPage(newPage); setPage(newPage);
@ -88,10 +91,11 @@ export function ProductsPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!productToDelete) return; if (!productToDelete) return;
setIsDeleting(true);
try { try {
await deleteProduct(productToDelete.id); await deleteProduct(productToDelete.id);
refetch();
} finally { } finally {
setIsDeleting(false);
setDeleteModalOpen(false); setDeleteModalOpen(false);
setProductToDelete(null); setProductToDelete(null);
} }
@ -284,7 +288,7 @@ export function ProductsPage() {
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<p className="text-red-500">Error al cargar productos</p> <p className="text-red-500">Error al cargar productos</p>
<Button variant="link" onClick={() => refetch()} className="mt-2"> <Button variant="link" onClick={() => refresh()} className="mt-2">
Reintentar Reintentar
</Button> </Button>
</div> </div>

View File

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

View File

@ -44,6 +44,7 @@ export interface Product {
createdBy: string | null; createdBy: string | null;
updatedAt: string; updatedAt: string;
updatedBy: string | null; updatedBy: string | null;
deletedAt?: string | null;
} }
export interface CreateProductDto { export interface CreateProductDto {
@ -57,7 +58,25 @@ export interface CreateProductDto {
price?: number; price?: number;
cost?: number; cost?: number;
currency?: string; currency?: string;
taxIncluded?: boolean;
taxRate?: number; 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<string, unknown>;
isActive?: boolean; isActive?: boolean;
isSellable?: boolean; isSellable?: boolean;
isPurchasable?: boolean; isPurchasable?: boolean;
@ -74,21 +93,43 @@ export interface UpdateProductDto {
price?: number; price?: number;
cost?: number; cost?: number;
currency?: string; currency?: string;
taxIncluded?: boolean;
taxRate?: number; taxRate?: number;
taxCode?: string | null;
uom?: string;
uomPurchase?: string | null;
uomConversion?: number;
trackInventory?: boolean;
minStock?: number;
maxStock?: number | null;
reorderPoint?: number | null;
leadTimeDays?: number;
weight?: number | null;
length?: number | null;
width?: number | null;
height?: number | null;
volume?: number | null;
imageUrl?: string | null;
images?: string[];
attributes?: Record<string, unknown>;
isActive?: boolean; isActive?: boolean;
isSellable?: boolean; isSellable?: boolean;
isPurchasable?: boolean; isPurchasable?: boolean;
} }
export interface ProductSearchParams { export interface ProductFilters {
search?: string; search?: string;
categoryId?: string; categoryId?: string;
productType?: ProductType; productType?: ProductType;
isActive?: boolean; isActive?: boolean;
isSellable?: boolean; isSellable?: boolean;
isPurchasable?: boolean; isPurchasable?: boolean;
minPrice?: number;
maxPrice?: number;
page?: number;
limit?: number; limit?: number;
offset?: number; sortBy?: string;
sortOrder?: 'asc' | 'desc';
} }
// ==================== Category Types ==================== // ==================== Category Types ====================
@ -108,6 +149,8 @@ export interface ProductCategory {
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt?: string | null;
children?: ProductCategory[];
} }
export interface CreateCategoryDto { export interface CreateCategoryDto {
@ -115,6 +158,8 @@ export interface CreateCategoryDto {
name: string; name: string;
description?: string; description?: string;
parentId?: string; parentId?: string;
imageUrl?: string;
sortOrder?: number;
isActive?: boolean; isActive?: boolean;
} }
@ -123,15 +168,19 @@ export interface UpdateCategoryDto {
name?: string; name?: string;
description?: string | null; description?: string | null;
parentId?: string | null; parentId?: string | null;
imageUrl?: string | null;
sortOrder?: number;
isActive?: boolean; isActive?: boolean;
} }
export interface CategorySearchParams { export interface CategoryFilters {
search?: string; search?: string;
parentId?: string; parentId?: string;
isActive?: boolean; isActive?: boolean;
page?: number;
limit?: number; limit?: number;
offset?: number; sortBy?: string;
sortOrder?: 'asc' | 'desc';
} }
// ==================== Variant Types ==================== // ==================== Variant Types ====================
@ -155,6 +204,28 @@ export interface ProductVariant {
updatedBy: string | null; 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 ==================== // ==================== Price Types ====================
export type PriceType = 'standard' | 'wholesale' | 'retail' | 'promo'; export type PriceType = 'standard' | 'wholesale' | 'retail' | 'promo';
@ -176,7 +247,6 @@ export interface ProductPrice {
} }
export interface CreatePriceDto { export interface CreatePriceDto {
productId: string;
priceType?: PriceType; priceType?: PriceType;
priceListName?: string; priceListName?: string;
price: number; price: number;
@ -232,6 +302,42 @@ export interface ProductAttributeValue {
updatedAt: string; 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 ==================== // ==================== Supplier Types ====================
export interface ProductSupplier { export interface ProductSupplier {
@ -251,20 +357,47 @@ export interface ProductSupplier {
updatedAt: string; 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 ==================== // ==================== Response Types ====================
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ProductsResponse { export interface ProductsResponse {
data: Product[]; data: Product[];
total: number; meta: PaginationMeta;
limit: number;
offset: number;
} }
export interface CategoriesResponse { export interface CategoriesResponse {
data: ProductCategory[]; data: ProductCategory[];
total: number; meta: PaginationMeta;
limit: number;
offset: number;
} }
export interface PricesResponse { export interface PricesResponse {
@ -272,8 +405,26 @@ export interface PricesResponse {
total: number; total: number;
} }
export interface VariantsResponse {
data: ProductVariant[];
total: number;
}
export interface AttributesResponse {
data: ProductAttribute[];
total: number;
}
// ==================== Tree Node Types ==================== // ==================== Tree Node Types ====================
export interface CategoryTreeNode extends ProductCategory { export interface CategoryTreeNode extends ProductCategory {
children: CategoryTreeNode[]; children: CategoryTreeNode[];
} }
// ==================== Backward Compatibility Aliases ====================
/** @deprecated Use ProductFilters instead */
export type ProductSearchParams = ProductFilters;
/** @deprecated Use CategoryFilters instead */
export type CategorySearchParams = CategoryFilters;

View File

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

View File

@ -3,10 +3,13 @@ import type { ApiResponse } from '@shared/types/api.types';
import type { import type {
Warehouse, Warehouse,
WarehouseLocation, WarehouseLocation,
WarehouseZone,
CreateWarehouseDto, CreateWarehouseDto,
UpdateWarehouseDto, UpdateWarehouseDto,
CreateLocationDto, CreateLocationDto,
UpdateLocationDto, UpdateLocationDto,
CreateZoneDto,
UpdateZoneDto,
WarehouseFilters, WarehouseFilters,
LocationFilters, LocationFilters,
WarehousesResponse, WarehousesResponse,
@ -159,3 +162,63 @@ export const locationsApi = {
} }
}, },
}; };
// ==================== Zones API ====================
export const zonesApi = {
// List all zones for a warehouse
getByWarehouse: async (warehouseId: string): Promise<WarehouseZone[]> => {
const response = await api.get<ApiResponse<WarehouseZone[]>>(
`${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<WarehouseZone> => {
const response = await api.get<ApiResponse<WarehouseZone>>(
`${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<WarehouseZone> => {
const response = await api.post<ApiResponse<WarehouseZone>>(
`${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<WarehouseZone> => {
const response = await api.patch<ApiResponse<WarehouseZone>>(
`${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<void> => {
const response = await api.delete<ApiResponse>(
`${BASE_URL}/${warehouseId}/zones/${zoneId}`
);
if (!response.data.success) {
throw new Error(response.data.error || 'Error al eliminar zona');
}
},
};

View File

@ -15,3 +15,10 @@ export {
useLocationTree, useLocationTree,
} from './useLocations'; } from './useLocations';
export type { UseLocationsOptions } from './useLocations'; export type { UseLocationsOptions } from './useLocations';
// Zones hooks
export {
useZones,
useZone,
} from './useZones';
export type { UseZonesOptions } from './useZones';

View File

@ -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<WarehouseZone[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<WarehouseZone | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View File

@ -1,5 +1,5 @@
// API // API
export { warehousesApi, locationsApi } from './api'; export { warehousesApi, locationsApi, zonesApi } from './api';
// Types // Types
export type { export type {
@ -35,8 +35,10 @@ export {
useAllLocations, useAllLocations,
useLocation, useLocation,
useLocationTree, useLocationTree,
useZones,
useZone,
} from './hooks'; } from './hooks';
export type { UseWarehousesOptions, UseLocationsOptions } from './hooks'; export type { UseWarehousesOptions, UseLocationsOptions, UseZonesOptions } from './hooks';
// Components // Components
export { export {