[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:
parent
b5cffbff5f
commit
b9068be3d9
@ -1,20 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { companiesApi } from '../api';
|
||||
import type { BranchesResponse } from '../types';
|
||||
|
||||
const QUERY_KEY = 'companies';
|
||||
import type { Branch } from '../types';
|
||||
|
||||
// ==================== Company Branches Hook ====================
|
||||
|
||||
export function useCompanyBranches(companyId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'branches', companyId],
|
||||
queryFn: () => companiesApi.getBranches(companyId as string),
|
||||
enabled: !!companyId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
select: (response: BranchesResponse) => ({
|
||||
branches: response.data,
|
||||
total: response.total,
|
||||
}),
|
||||
});
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchBranches = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await companiesApi.getBranches(companyId);
|
||||
setBranches(response.data || []);
|
||||
setTotal(response.total || 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar sucursales');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [companyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (companyId) {
|
||||
fetchBranches();
|
||||
}
|
||||
}, [fetchBranches, companyId]);
|
||||
|
||||
return {
|
||||
branches,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchBranches,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,38 +1,54 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { companiesApi } from '../api';
|
||||
import type { CompanySettings } from '../types';
|
||||
|
||||
const QUERY_KEY = 'companies';
|
||||
|
||||
// ==================== Company Settings Hook ====================
|
||||
|
||||
export function useCompanySettings(companyId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'settings', companyId],
|
||||
queryFn: () => companiesApi.getSettings(companyId as string),
|
||||
enabled: !!companyId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Company Settings Mutations Hook ====================
|
||||
const fetchSettings = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await companiesApi.getSettings(companyId);
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar configuración');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [companyId]);
|
||||
|
||||
export function useCompanySettingsMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (companyId) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [fetchSettings, companyId]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ companyId, settings }: { companyId: string; settings: CompanySettings }) =>
|
||||
companiesApi.updateSettings(companyId, settings),
|
||||
onSuccess: (_: unknown, variables: { companyId: string; settings: CompanySettings }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'settings', variables.companyId] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.companyId] });
|
||||
},
|
||||
});
|
||||
const updateSettings = async (newSettings: CompanySettings) => {
|
||||
if (!companyId) throw new Error('Company ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await companiesApi.updateSettings(companyId, newSettings);
|
||||
setSettings(updated);
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar configuración');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
updateSettings: updateMutation.mutateAsync,
|
||||
isUpdating: updateMutation.isPending,
|
||||
updateError: updateMutation.error,
|
||||
settings,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchSettings,
|
||||
updateSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,88 +1,105 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { addressesApi } from '../api/addresses.api';
|
||||
import type {
|
||||
PartnerAddress,
|
||||
CreatePartnerAddressDto,
|
||||
UpdatePartnerAddressDto,
|
||||
} from '../types';
|
||||
|
||||
const QUERY_KEY = 'partner-addresses';
|
||||
|
||||
// ==================== Addresses List Hook ====================
|
||||
|
||||
export function usePartnerAddresses(partnerId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, partnerId],
|
||||
queryFn: () => addressesApi.getByPartnerId(partnerId as string),
|
||||
enabled: !!partnerId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
const [addresses, setAddresses] = useState<PartnerAddress[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Address Mutations Hook ====================
|
||||
const fetchAddresses = useCallback(async () => {
|
||||
if (!partnerId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await addressesApi.getByPartnerId(partnerId);
|
||||
setAddresses(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar direcciones');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [partnerId]);
|
||||
|
||||
export function usePartnerAddressMutations(partnerId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (partnerId) {
|
||||
fetchAddresses();
|
||||
}
|
||||
}, [fetchAddresses, partnerId]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreatePartnerAddressDto) => addressesApi.create(partnerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ addressId, data }: { addressId: string; data: UpdatePartnerAddressDto }) =>
|
||||
addressesApi.update(partnerId, addressId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (addressId: string) => addressesApi.delete(partnerId, addressId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: (addressId: string) => addressesApi.setDefault(partnerId, addressId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
setDefault: setDefaultMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isSettingDefault: setDefaultMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
const createAddress = async (data: CreatePartnerAddressDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newAddress = await addressesApi.create(partnerId, data);
|
||||
await fetchAddresses();
|
||||
return newAddress;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear dirección');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Combined Hook ====================
|
||||
const updateAddress = async (addressId: string, data: UpdatePartnerAddressDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await addressesApi.update(partnerId, addressId, data);
|
||||
await fetchAddresses();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar dirección');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
export function usePartnerAddressesWithMutations(partnerId: string | null | undefined) {
|
||||
const { data, isLoading, error, refetch } = usePartnerAddresses(partnerId);
|
||||
const mutations = usePartnerAddressMutations(partnerId || '');
|
||||
const deleteAddress = async (addressId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await addressesApi.delete(partnerId, addressId);
|
||||
await fetchAddresses();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar dirección');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaultAddress = async (addressId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await addressesApi.setDefault(partnerId, addressId);
|
||||
await fetchAddresses();
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al establecer dirección por defecto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
addresses: data || [],
|
||||
addresses,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
...mutations,
|
||||
// Disable mutations if no partnerId
|
||||
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
|
||||
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
|
||||
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
|
||||
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
|
||||
refresh: fetchAddresses,
|
||||
createAddress,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
setDefaultAddress,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,98 +1,121 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { bankAccountsApi } from '../api/bankAccounts.api';
|
||||
import type {
|
||||
PartnerBankAccount,
|
||||
CreatePartnerBankAccountDto,
|
||||
UpdatePartnerBankAccountDto,
|
||||
} from '../types';
|
||||
|
||||
const QUERY_KEY = 'partner-bank-accounts';
|
||||
|
||||
// ==================== Bank Accounts List Hook ====================
|
||||
|
||||
export function usePartnerBankAccounts(partnerId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, partnerId],
|
||||
queryFn: () => bankAccountsApi.getByPartnerId(partnerId as string),
|
||||
enabled: !!partnerId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
const [bankAccounts, setBankAccounts] = useState<PartnerBankAccount[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Bank Account Mutations Hook ====================
|
||||
const fetchBankAccounts = useCallback(async () => {
|
||||
if (!partnerId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await bankAccountsApi.getByPartnerId(partnerId);
|
||||
setBankAccounts(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar cuentas bancarias');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [partnerId]);
|
||||
|
||||
export function usePartnerBankAccountMutations(partnerId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (partnerId) {
|
||||
fetchBankAccounts();
|
||||
}
|
||||
}, [fetchBankAccounts, partnerId]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreatePartnerBankAccountDto) => bankAccountsApi.create(partnerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ accountId, data }: { accountId: string; data: UpdatePartnerBankAccountDto }) =>
|
||||
bankAccountsApi.update(partnerId, accountId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (accountId: string) => bankAccountsApi.delete(partnerId, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: (accountId: string) => bankAccountsApi.verify(partnerId, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: (accountId: string) => bankAccountsApi.setDefault(partnerId, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
verify: verifyMutation.mutateAsync,
|
||||
setDefault: setDefaultMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isVerifying: verifyMutation.isPending,
|
||||
isSettingDefault: setDefaultMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
const createBankAccount = async (data: CreatePartnerBankAccountDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newAccount = await bankAccountsApi.create(partnerId, data);
|
||||
await fetchBankAccounts();
|
||||
return newAccount;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear cuenta bancaria');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Combined Hook ====================
|
||||
const updateBankAccount = async (accountId: string, data: UpdatePartnerBankAccountDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await bankAccountsApi.update(partnerId, accountId, data);
|
||||
await fetchBankAccounts();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar cuenta bancaria');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
export function usePartnerBankAccountsWithMutations(partnerId: string | null | undefined) {
|
||||
const { data, isLoading, error, refetch } = usePartnerBankAccounts(partnerId);
|
||||
const mutations = usePartnerBankAccountMutations(partnerId || '');
|
||||
const deleteBankAccount = async (accountId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await bankAccountsApi.delete(partnerId, accountId);
|
||||
await fetchBankAccounts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar cuenta bancaria');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyBankAccount = async (accountId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await bankAccountsApi.verify(partnerId, accountId);
|
||||
await fetchBankAccounts();
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al verificar cuenta bancaria');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaultBankAccount = async (accountId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await bankAccountsApi.setDefault(partnerId, accountId);
|
||||
await fetchBankAccounts();
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al establecer cuenta por defecto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
bankAccounts: data || [],
|
||||
bankAccounts,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
...mutations,
|
||||
// Disable mutations if no partnerId
|
||||
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
|
||||
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
|
||||
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
|
||||
verify: partnerId ? mutations.verify : async () => { throw new Error('Partner ID required'); },
|
||||
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
|
||||
refresh: fetchBankAccounts,
|
||||
createBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
verifyBankAccount,
|
||||
setDefaultBankAccount,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,88 +1,105 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { contactsApi } from '../api/contacts.api';
|
||||
import type {
|
||||
PartnerContact,
|
||||
CreatePartnerContactDto,
|
||||
UpdatePartnerContactDto,
|
||||
} from '../types';
|
||||
|
||||
const QUERY_KEY = 'partner-contacts';
|
||||
|
||||
// ==================== Contacts List Hook ====================
|
||||
|
||||
export function usePartnerContacts(partnerId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, partnerId],
|
||||
queryFn: () => contactsApi.getByPartnerId(partnerId as string),
|
||||
enabled: !!partnerId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
const [contacts, setContacts] = useState<PartnerContact[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Contact Mutations Hook ====================
|
||||
const fetchContacts = useCallback(async () => {
|
||||
if (!partnerId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await contactsApi.getByPartnerId(partnerId);
|
||||
setContacts(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar contactos');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [partnerId]);
|
||||
|
||||
export function usePartnerContactMutations(partnerId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (partnerId) {
|
||||
fetchContacts();
|
||||
}
|
||||
}, [fetchContacts, partnerId]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreatePartnerContactDto) => contactsApi.create(partnerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ contactId, data }: { contactId: string; data: UpdatePartnerContactDto }) =>
|
||||
contactsApi.update(partnerId, contactId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (contactId: string) => contactsApi.delete(partnerId, contactId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const setPrimaryMutation = useMutation({
|
||||
mutationFn: (contactId: string) => contactsApi.setPrimary(partnerId, contactId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
setPrimary: setPrimaryMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isSettingPrimary: setPrimaryMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
const createContact = async (data: CreatePartnerContactDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newContact = await contactsApi.create(partnerId, data);
|
||||
await fetchContacts();
|
||||
return newContact;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear contacto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Combined Hook ====================
|
||||
const updateContact = async (contactId: string, data: UpdatePartnerContactDto) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await contactsApi.update(partnerId, contactId, data);
|
||||
await fetchContacts();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar contacto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
export function usePartnerContactsWithMutations(partnerId: string | null | undefined) {
|
||||
const { data, isLoading, error, refetch } = usePartnerContacts(partnerId);
|
||||
const mutations = usePartnerContactMutations(partnerId || '');
|
||||
const deleteContact = async (contactId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await contactsApi.delete(partnerId, contactId);
|
||||
await fetchContacts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar contacto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setPrimaryContact = async (contactId: string) => {
|
||||
if (!partnerId) throw new Error('Partner ID required');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await contactsApi.setPrimary(partnerId, contactId);
|
||||
await fetchContacts();
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al establecer contacto principal');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
contacts: data || [],
|
||||
contacts,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
...mutations,
|
||||
// Disable mutations if no partnerId
|
||||
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
|
||||
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
|
||||
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
|
||||
setPrimary: partnerId ? mutations.setPrimary : async () => { throw new Error('Partner ID required'); },
|
||||
refresh: fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
setPrimaryContact,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
ProductCategory,
|
||||
CreateCategoryDto,
|
||||
UpdateCategoryDto,
|
||||
CategorySearchParams,
|
||||
CategoryFilters,
|
||||
CategoriesResponse,
|
||||
CategoryTreeNode,
|
||||
} from '../types';
|
||||
@ -11,86 +11,68 @@ import type {
|
||||
const CATEGORIES_URL = '/api/v1/products/categories';
|
||||
|
||||
export const categoriesApi = {
|
||||
// Get all categories with filters
|
||||
getAll: async (params?: CategorySearchParams): Promise<CategoriesResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.search) searchParams.append('search', params.search);
|
||||
if (params?.parentId) searchParams.append('parentId', params.parentId);
|
||||
if (params?.isActive !== undefined) searchParams.append('isActive', String(params.isActive));
|
||||
if (params?.limit) searchParams.append('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.append('offset', String(params.offset));
|
||||
getAll: async (filters?: CategoryFilters): Promise<CategoriesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.parentId) params.append('parentId', filters.parentId);
|
||||
if (filters?.isActive !== undefined) params.append('isActive', String(filters.isActive));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `${CATEGORIES_URL}?${queryString}` : CATEGORIES_URL;
|
||||
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number; limit: number; offset: number }>(url);
|
||||
const response = await api.get<CategoriesResponse>(url);
|
||||
|
||||
return {
|
||||
data: response.data.data || [],
|
||||
total: response.data.total || 0,
|
||||
limit: response.data.limit || 50,
|
||||
offset: response.data.offset || 0,
|
||||
meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 },
|
||||
};
|
||||
},
|
||||
|
||||
// Get category by ID
|
||||
getById: async (id: string): Promise<ProductCategory> => {
|
||||
const response = await api.get<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Categoria no encontrada');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.get<ProductCategory>(`${CATEGORIES_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create category
|
||||
create: async (data: CreateCategoryDto): Promise<ProductCategory> => {
|
||||
const response = await api.post<{ success: boolean; data: ProductCategory }>(CATEGORIES_URL, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al crear categoria');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.post<ProductCategory>(CATEGORIES_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update category
|
||||
update: async (id: string, data: UpdateCategoryDto): Promise<ProductCategory> => {
|
||||
const response = await api.patch<{ success: boolean; data: ProductCategory }>(`${CATEGORIES_URL}/${id}`, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al actualizar categoria');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.patch<ProductCategory>(`${CATEGORIES_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete category
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${CATEGORIES_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get root categories (no parent)
|
||||
getRoots: async (): Promise<ProductCategory[]> => {
|
||||
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>(
|
||||
`${CATEGORIES_URL}?parentId=`
|
||||
);
|
||||
const response = await api.get<CategoriesResponse>(`${CATEGORIES_URL}?parentId=`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
// Get children of a category
|
||||
getChildren: async (parentId: string): Promise<ProductCategory[]> => {
|
||||
const response = await api.get<{ success: boolean; data: ProductCategory[]; total: number }>(
|
||||
`${CATEGORIES_URL}?parentId=${parentId}`
|
||||
);
|
||||
const response = await api.get<CategoriesResponse>(`${CATEGORIES_URL}?parentId=${parentId}`);
|
||||
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[] => {
|
||||
const map = new Map<string, CategoryTreeNode>();
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
|
||||
// Create nodes
|
||||
categories.forEach((cat) => {
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
|
||||
// Build tree
|
||||
categories.forEach((cat) => {
|
||||
const node = map.get(cat.id);
|
||||
if (!node) return;
|
||||
@ -103,7 +85,6 @@ export const categoriesApi = {
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by sortOrder
|
||||
const sortNodes = (nodes: CategoryTreeNode[]): CategoryTreeNode[] => {
|
||||
nodes.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
nodes.forEach((node) => {
|
||||
@ -116,4 +97,14 @@ export const categoriesApi = {
|
||||
|
||||
return sortNodes(roots);
|
||||
},
|
||||
|
||||
activate: async (id: string): Promise<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;
|
||||
},
|
||||
};
|
||||
|
||||
3
src/features/products/api/index.ts
Normal file
3
src/features/products/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { productsApi } from './products.api';
|
||||
export { categoriesApi } from './categories.api';
|
||||
export { pricingApi } from './pricing.api';
|
||||
64
src/features/products/api/pricing.api.ts
Normal file
64
src/features/products/api/pricing.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -3,110 +3,129 @@ import type {
|
||||
Product,
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
ProductSearchParams,
|
||||
ProductFilters,
|
||||
ProductsResponse,
|
||||
ProductVariant,
|
||||
CreateVariantDto,
|
||||
UpdateVariantDto,
|
||||
VariantsResponse,
|
||||
ProductAttribute,
|
||||
AttributesResponse,
|
||||
} from '../types';
|
||||
|
||||
const PRODUCTS_URL = '/api/v1/products';
|
||||
|
||||
export const productsApi = {
|
||||
// Get all products with filters
|
||||
getAll: async (params?: ProductSearchParams): Promise<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));
|
||||
// ==================== Products CRUD ====================
|
||||
|
||||
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 response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(url);
|
||||
const response = await api.get<ProductsResponse>(url);
|
||||
|
||||
return {
|
||||
data: response.data.data || [],
|
||||
total: response.data.total || 0,
|
||||
limit: response.data.limit || 50,
|
||||
offset: response.data.offset || 0,
|
||||
meta: response.data.meta || { total: 0, page: 1, limit: 10, totalPages: 1 },
|
||||
};
|
||||
},
|
||||
|
||||
// Get product by ID
|
||||
getById: async (id: string): Promise<Product> => {
|
||||
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Producto no encontrado');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.get<Product>(`${PRODUCTS_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get product by SKU
|
||||
getBySku: async (sku: string): Promise<Product> => {
|
||||
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/sku/${sku}`);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Producto no encontrado');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.get<Product>(`${PRODUCTS_URL}/sku/${sku}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get product by barcode
|
||||
getByBarcode: async (barcode: string): Promise<Product> => {
|
||||
const response = await api.get<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/barcode/${barcode}`);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Producto no encontrado');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.get<Product>(`${PRODUCTS_URL}/barcode/${barcode}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create product
|
||||
create: async (data: CreateProductDto): Promise<Product> => {
|
||||
const response = await api.post<{ success: boolean; data: Product }>(PRODUCTS_URL, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al crear producto');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.post<Product>(PRODUCTS_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update product
|
||||
update: async (id: string, data: UpdateProductDto): Promise<Product> => {
|
||||
const response = await api.patch<{ success: boolean; data: Product }>(`${PRODUCTS_URL}/${id}`, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al actualizar producto');
|
||||
}
|
||||
return response.data.data;
|
||||
const response = await api.patch<Product>(`${PRODUCTS_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete product
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${PRODUCTS_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get sellable products
|
||||
getSellable: async (limit = 50, offset = 0): Promise<ProductsResponse> => {
|
||||
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(
|
||||
`${PRODUCTS_URL}/sellable?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data || [],
|
||||
total: response.data.total || 0,
|
||||
limit: response.data.limit || limit,
|
||||
offset: response.data.offset || offset,
|
||||
};
|
||||
// ==================== Product Variants ====================
|
||||
|
||||
getVariants: async (productId: string): Promise<VariantsResponse> => {
|
||||
const response = await api.get<VariantsResponse>(`${PRODUCTS_URL}/${productId}/variants`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get purchasable products
|
||||
getPurchasable: async (limit = 50, offset = 0): Promise<ProductsResponse> => {
|
||||
const response = await api.get<{ success: boolean; data: Product[]; total: number; limit: number; offset: number }>(
|
||||
`${PRODUCTS_URL}/purchasable?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
return {
|
||||
data: response.data.data || [],
|
||||
total: response.data.total || 0,
|
||||
limit: response.data.limit || limit,
|
||||
offset: response.data.offset || offset,
|
||||
};
|
||||
createVariant: async (productId: string, data: CreateVariantDto): Promise<ProductVariant> => {
|
||||
const response = await api.post<ProductVariant>(`${PRODUCTS_URL}/${productId}/variants`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateVariant: async (productId: string, variantId: string, data: UpdateVariantDto): Promise<ProductVariant> => {
|
||||
const response = await api.patch<ProductVariant>(`${PRODUCTS_URL}/${productId}/variants/${variantId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
262
src/features/products/components/AttributeEditor.tsx
Normal file
262
src/features/products/components/AttributeEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useProductPrices, useProductPriceMutations, usePricingHelpers } from '../hooks';
|
||||
import { useProductPrices, usePricingHelpers } from '../hooks';
|
||||
import type { ProductPrice, PriceType } from '../types';
|
||||
|
||||
const priceTypeConfig: Record<PriceType, { label: string; color: 'info' | 'success' | 'warning' | 'primary' }> = {
|
||||
@ -35,20 +35,24 @@ export function PricingTable({
|
||||
onEditPrice,
|
||||
className,
|
||||
}: PricingTableProps) {
|
||||
const { data, isLoading, error, refetch } = useProductPrices(productId);
|
||||
const { delete: deletePrice, isDeleting } = useProductPriceMutations();
|
||||
const {
|
||||
prices,
|
||||
isLoading,
|
||||
error,
|
||||
deletePrice
|
||||
} = useProductPrices(productId);
|
||||
const { formatPrice, calculateMargin } = usePricingHelpers();
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [priceToDelete, setPriceToDelete] = useState<ProductPrice | null>(null);
|
||||
|
||||
const prices = data?.data || [];
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!priceToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deletePrice(priceToDelete.id);
|
||||
refetch();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
setPriceToDelete(null);
|
||||
}
|
||||
|
||||
261
src/features/products/components/ProductTable.tsx
Normal file
261
src/features/products/components/ProductTable.tsx
Normal 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' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,9 @@ export type { ProductFormProps } from './ProductForm';
|
||||
export { ProductCard } from './ProductCard';
|
||||
export type { ProductCardProps } from './ProductCard';
|
||||
|
||||
export { ProductTable } from './ProductTable';
|
||||
export type { ProductTableProps } from './ProductTable';
|
||||
|
||||
export { CategoryTree } from './CategoryTree';
|
||||
export type { CategoryTreeProps } from './CategoryTree';
|
||||
|
||||
@ -12,3 +15,6 @@ export type { VariantSelectorProps } from './VariantSelector';
|
||||
|
||||
export { PricingTable } from './PricingTable';
|
||||
export type { PricingTableProps } from './PricingTable';
|
||||
|
||||
export { AttributeEditor } from './AttributeEditor';
|
||||
export type { AttributeEditorProps } from './AttributeEditor';
|
||||
|
||||
@ -3,10 +3,6 @@ export {
|
||||
useProducts,
|
||||
useProduct,
|
||||
useProductBySku,
|
||||
useProductByBarcode,
|
||||
useSellableProducts,
|
||||
usePurchasableProducts,
|
||||
useProductMutations,
|
||||
useProductSearch,
|
||||
} from './useProducts';
|
||||
export type { UseProductsOptions } from './useProducts';
|
||||
@ -18,7 +14,6 @@ export {
|
||||
useCategoryTree,
|
||||
useRootCategories,
|
||||
useChildCategories,
|
||||
useCategoryMutations,
|
||||
useCategoryOptions,
|
||||
} from './useCategories';
|
||||
export type { UseCategoriesOptions } from './useCategories';
|
||||
@ -27,6 +22,11 @@ export type { UseCategoriesOptions } from './useCategories';
|
||||
export {
|
||||
useProductPrices,
|
||||
useProductPrice,
|
||||
useProductPriceMutations,
|
||||
usePricingHelpers,
|
||||
} from './useProductPricing';
|
||||
|
||||
// Variants Hooks
|
||||
export {
|
||||
useProductVariants,
|
||||
useProductVariant,
|
||||
} from './useProductVariants';
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { categoriesApi } from '../api/categories.api';
|
||||
import type {
|
||||
ProductCategory,
|
||||
@ -9,134 +8,250 @@ import type {
|
||||
CategoryTreeNode,
|
||||
} from '../types';
|
||||
|
||||
const QUERY_KEY = 'product-categories';
|
||||
|
||||
export interface UseCategoriesOptions extends CategorySearchParams {
|
||||
enabled?: boolean;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Categories List Hook ====================
|
||||
|
||||
export function useCategories(options: UseCategoriesOptions = {}) {
|
||||
const { enabled = true, ...params } = options;
|
||||
const { autoFetch = true, ...params } = options;
|
||||
const [categories, setCategories] = useState<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({
|
||||
queryKey: [QUERY_KEY, 'list', params],
|
||||
queryFn: () => categoriesApi.getAll(params),
|
||||
enabled,
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes - categories change less frequently
|
||||
});
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await categoriesApi.getAll({ ...params, page });
|
||||
setCategories(response.data);
|
||||
setTotal(response.meta.total);
|
||||
setTotalPages(response.meta.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar categorías');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.search, params.parentId, params.isActive, params.limit, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchCategories();
|
||||
}
|
||||
}, [fetchCategories, autoFetch]);
|
||||
|
||||
const createCategory = async (data: CreateCategoryDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newCategory = await categoriesApi.create(data);
|
||||
await fetchCategories();
|
||||
return newCategory;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear categoría');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCategory = async (id: string, data: UpdateCategoryDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await categoriesApi.update(id, data);
|
||||
await fetchCategories();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar categoría');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCategory = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await categoriesApi.delete(id);
|
||||
await fetchCategories();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar categoría');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
categories,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh: fetchCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Single Category Hook ====================
|
||||
|
||||
export function useCategory(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'detail', id],
|
||||
queryFn: () => categoriesApi.getById(id as string),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
const [category, setCategory] = useState<ProductCategory | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCategory = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await categoriesApi.getById(id);
|
||||
setCategory(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar categoría');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchCategory();
|
||||
}
|
||||
}, [fetchCategory, id]);
|
||||
|
||||
return {
|
||||
category,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchCategory,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Category Tree Hook ====================
|
||||
|
||||
export function useCategoryTree() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: [QUERY_KEY, 'all'],
|
||||
queryFn: () => categoriesApi.getAll({ limit: 1000 }), // Get all for tree
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
const [allCategories, setAllCategories] = useState<ProductCategory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAllCategories = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await categoriesApi.getAll({ limit: 1000 });
|
||||
setAllCategories(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar categorías');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllCategories();
|
||||
}, [fetchAllCategories]);
|
||||
|
||||
const tree = useMemo<CategoryTreeNode[]>(() => {
|
||||
if (!data?.data) return [];
|
||||
return categoriesApi.buildTree(data.data);
|
||||
}, [data?.data]);
|
||||
if (allCategories.length === 0) return [];
|
||||
return categoriesApi.buildTree(allCategories);
|
||||
}, [allCategories]);
|
||||
|
||||
return {
|
||||
tree,
|
||||
categories: data?.data || [],
|
||||
categories: allCategories,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
refresh: fetchAllCategories,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Root Categories Hook ====================
|
||||
|
||||
export function useRootCategories() {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'roots'],
|
||||
queryFn: () => categoriesApi.getRoots(),
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
const [categories, setCategories] = useState<ProductCategory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRoots = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await categoriesApi.getRoots();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar categorías raíz');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoots();
|
||||
}, [fetchRoots]);
|
||||
|
||||
return {
|
||||
categories,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchRoots,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Child Categories Hook ====================
|
||||
|
||||
export function useChildCategories(parentId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'children', parentId],
|
||||
queryFn: () => categoriesApi.getChildren(parentId as string),
|
||||
enabled: !!parentId,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
});
|
||||
}
|
||||
const [categories, setCategories] = useState<ProductCategory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Category Mutations Hook ====================
|
||||
const fetchChildren = useCallback(async () => {
|
||||
if (!parentId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await categoriesApi.getChildren(parentId);
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar subcategorías');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [parentId]);
|
||||
|
||||
export function useCategoryMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateCategoryDto) => categoriesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCategoryDto }) =>
|
||||
categoriesApi.update(id, data),
|
||||
onSuccess: (_: unknown, variables: { id: string; data: UpdateCategoryDto }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => categoriesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
fetchChildren();
|
||||
}
|
||||
}, [fetchChildren, parentId]);
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
categories,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchChildren,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Category Options Hook (for selects) ====================
|
||||
|
||||
export function useCategoryOptions() {
|
||||
const { data, isLoading } = useCategories({ isActive: true, limit: 500 });
|
||||
const { categories, isLoading } = useCategories({ isActive: true, limit: 500 });
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
return data.data.map((cat: ProductCategory) => ({
|
||||
return categories.map((cat: ProductCategory) => ({
|
||||
value: cat.id,
|
||||
label: cat.hierarchyPath ? `${cat.hierarchyPath} / ${cat.name}` : cat.name,
|
||||
category: cat,
|
||||
}));
|
||||
}, [data?.data]);
|
||||
}, [categories]);
|
||||
|
||||
return { options, isLoading };
|
||||
}
|
||||
|
||||
@ -1,112 +1,134 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type { ProductPrice, CreatePriceDto, UpdatePriceDto, PricesResponse } from '../types';
|
||||
import type { ProductPrice, CreatePriceDto, UpdatePriceDto } from '../types';
|
||||
|
||||
const PRICES_URL = '/api/v1/products/prices';
|
||||
const QUERY_KEY = 'product-prices';
|
||||
|
||||
// ==================== API Functions ====================
|
||||
|
||||
const pricesApi = {
|
||||
getByProduct: async (productId: string): Promise<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 ====================
|
||||
|
||||
export function useProductPrices(productId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'byProduct', productId],
|
||||
queryFn: () => pricesApi.getByProduct(productId as string),
|
||||
enabled: !!productId,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
const [prices, setPrices] = useState<ProductPrice[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPrices = useCallback(async () => {
|
||||
if (!productId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; data: ProductPrice[]; total: number }>(
|
||||
`${PRICES_URL}?productId=${productId}`
|
||||
);
|
||||
setPrices(response.data.data || []);
|
||||
setTotal(response.data.total || 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar precios');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
fetchPrices();
|
||||
}
|
||||
}, [fetchPrices, productId]);
|
||||
|
||||
const createPrice = async (data: CreatePriceDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.post<{ success: boolean; data: ProductPrice }>(PRICES_URL, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al crear precio');
|
||||
}
|
||||
await fetchPrices();
|
||||
return response.data.data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear precio');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePrice = async (priceId: string, data: UpdatePriceDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.patch<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${priceId}`, data);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Error al actualizar precio');
|
||||
}
|
||||
await fetchPrices();
|
||||
return response.data.data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar precio');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePrice = async (priceId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await api.delete(`${PRICES_URL}/${priceId}`);
|
||||
await fetchPrices();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar precio');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
prices,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchPrices,
|
||||
createPrice,
|
||||
updatePrice,
|
||||
deletePrice,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Single Price Hook ====================
|
||||
|
||||
export function useProductPrice(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'detail', id],
|
||||
queryFn: () => pricesApi.getById(id as string),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
const [price, setPrice] = useState<ProductPrice | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Price Mutations Hook ====================
|
||||
const fetchPrice = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; data: ProductPrice }>(`${PRICES_URL}/${id}`);
|
||||
if (!response.data.data) {
|
||||
throw new Error('Precio no encontrado');
|
||||
}
|
||||
setPrice(response.data.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar precio');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
export function useProductPriceMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreatePriceDto) => pricesApi.create(data),
|
||||
onSuccess: (_: unknown, variables: CreatePriceDto) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'byProduct', variables.productId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdatePriceDto }) => pricesApi.update(id, data),
|
||||
onSuccess: (_: unknown, variables: { id: string; data: UpdatePriceDto }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => pricesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchPrice();
|
||||
}
|
||||
}, [fetchPrice, id]);
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
price,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchPrice,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
121
src/features/products/hooks/useProductVariants.ts
Normal file
121
src/features/products/hooks/useProductVariants.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,121 +1,171 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi } from '../api/products.api';
|
||||
import type {
|
||||
Product,
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
ProductSearchParams,
|
||||
} from '../types';
|
||||
|
||||
const QUERY_KEY = 'products';
|
||||
|
||||
export interface UseProductsOptions extends ProductSearchParams {
|
||||
enabled?: boolean;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Products List Hook ====================
|
||||
|
||||
export function useProducts(options: UseProductsOptions = {}) {
|
||||
const { enabled = true, ...params } = options;
|
||||
const { autoFetch = true, ...params } = options;
|
||||
const [products, setProducts] = useState<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({
|
||||
queryKey: [QUERY_KEY, 'list', params],
|
||||
queryFn: () => productsApi.getAll(params),
|
||||
enabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
const fetchProducts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await productsApi.getAll({ ...params, page });
|
||||
setProducts(response.data);
|
||||
setTotal(response.meta.total);
|
||||
setTotalPages(response.meta.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar productos');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.search, params.categoryId, params.isActive, params.limit, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchProducts();
|
||||
}
|
||||
}, [fetchProducts, autoFetch]);
|
||||
|
||||
const createProduct = async (data: CreateProductDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newProduct = await productsApi.create(data);
|
||||
await fetchProducts();
|
||||
return newProduct;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear producto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProduct = async (id: string, data: UpdateProductDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await productsApi.update(id, data);
|
||||
await fetchProducts();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar producto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProduct = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await productsApi.delete(id);
|
||||
await fetchProducts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar producto');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
products,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh: fetchProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Single Product Hook ====================
|
||||
|
||||
export function useProduct(id: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'detail', id],
|
||||
queryFn: () => productsApi.getById(id as string),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProduct = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await productsApi.getById(id);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar producto');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchProduct();
|
||||
}
|
||||
}, [fetchProduct, id]);
|
||||
|
||||
return {
|
||||
product,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchProduct,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Product by SKU Hook ====================
|
||||
|
||||
export function useProductBySku(sku: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'bySku', sku],
|
||||
queryFn: () => productsApi.getBySku(sku as string),
|
||||
enabled: !!sku,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ==================== Product by Barcode Hook ====================
|
||||
const fetchProduct = useCallback(async () => {
|
||||
if (!sku) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await productsApi.getBySku(sku);
|
||||
setProduct(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar producto por SKU');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sku]);
|
||||
|
||||
export function useProductByBarcode(barcode: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'byBarcode', barcode],
|
||||
queryFn: () => productsApi.getByBarcode(barcode as string),
|
||||
enabled: !!barcode,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Sellable Products Hook ====================
|
||||
|
||||
export function useSellableProducts(limit = 50, offset = 0) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'sellable', limit, offset],
|
||||
queryFn: () => productsApi.getSellable(limit, offset),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Purchasable Products Hook ====================
|
||||
|
||||
export function usePurchasableProducts(limit = 50, offset = 0) {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'purchasable', limit, offset],
|
||||
queryFn: () => productsApi.getPurchasable(limit, offset),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Product Mutations Hook ====================
|
||||
|
||||
export function useProductMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateProductDto) => productsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
|
||||
productsApi.update(id, data),
|
||||
onSuccess: (_: unknown, variables: { id: string; data: UpdateProductDto }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => productsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (sku) {
|
||||
fetchProduct();
|
||||
}
|
||||
}, [fetchProduct, sku]);
|
||||
|
||||
return {
|
||||
create: createMutation.mutateAsync,
|
||||
update: updateMutation.mutateAsync,
|
||||
delete: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
product,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchProduct,
|
||||
};
|
||||
}
|
||||
|
||||
@ -123,11 +173,34 @@ export function useProductMutations() {
|
||||
|
||||
export function useProductSearch(searchTerm: string, options?: { limit?: number }) {
|
||||
const { limit = 10 } = options || {};
|
||||
const [results, setResults] = useState<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, 'search', searchTerm, limit],
|
||||
queryFn: () => productsApi.getAll({ search: searchTerm, limit }),
|
||||
enabled: searchTerm.length >= 2,
|
||||
staleTime: 1000 * 30, // 30 seconds for search results
|
||||
});
|
||||
const search = useCallback(async () => {
|
||||
if (searchTerm.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await productsApi.getAll({ search: searchTerm, limit });
|
||||
setResults(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error en búsqueda');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchTerm, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
return {
|
||||
results,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// API
|
||||
export { productsApi } from './api/products.api';
|
||||
export { categoriesApi } from './api/categories.api';
|
||||
export { pricingApi } from './api/pricing.api';
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
@ -10,7 +10,7 @@ import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useCategories, useCategoryMutations, useCategoryOptions } from '../hooks';
|
||||
import { useCategories, useCategoryOptions } from '../hooks';
|
||||
import { CategoryTree } from '../components';
|
||||
import type { ProductCategory, CreateCategoryDto, UpdateCategoryDto, CategorySearchParams } from '../types';
|
||||
|
||||
@ -28,7 +28,6 @@ export function CategoriesPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters] = useState<CategorySearchParams>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [viewMode, setViewMode] = useState<'tree' | 'table'>('tree');
|
||||
@ -41,17 +40,23 @@ export function CategoriesPage() {
|
||||
const queryParams: CategorySearchParams = {
|
||||
...filters,
|
||||
search: searchTerm || undefined,
|
||||
offset: (page - 1) * (filters.limit || 50),
|
||||
page,
|
||||
};
|
||||
|
||||
const { data, isLoading, refetch } = useCategories(queryParams);
|
||||
const { create, update, delete: deleteCategory, isCreating, isUpdating, isDeleting } = useCategoryMutations();
|
||||
const {
|
||||
categories,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory
|
||||
} = useCategories(queryParams);
|
||||
const { options: categoryOptions } = useCategoryOptions();
|
||||
|
||||
const categories = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const limit = filters.limit || 50;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -108,21 +113,34 @@ export function CategoriesPage() {
|
||||
};
|
||||
|
||||
if (editingCategory) {
|
||||
await update({ id: editingCategory.id, data: cleanData as UpdateCategoryDto });
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateCategory(editingCategory.id, cleanData as UpdateCategoryDto);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
} else {
|
||||
await create(cleanData as CreateCategoryDto);
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createCategory(cleanData as CreateCategoryDto);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
setFormModalOpen(false);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!categoryToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteCategory(categoryToDelete.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
setCategoryToDelete(null);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<ProductCategory>[] = [
|
||||
|
||||
472
src/features/products/pages/PricingPage.tsx
Normal file
472
src/features/products/pages/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -18,9 +18,9 @@ import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from '@components/organisms/Tabs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { useProduct, useProductMutations, usePricingHelpers } from '../hooks';
|
||||
import { useProduct, useProducts, usePricingHelpers } from '../hooks';
|
||||
import { ProductForm, PricingTable } from '../components';
|
||||
import type { Product, ProductType, UpdateProductDto } from '../types';
|
||||
import type { ProductType, UpdateProductDto } from '../types';
|
||||
|
||||
const productTypeLabels: Record<ProductType, string> = {
|
||||
product: 'Producto',
|
||||
@ -35,26 +35,33 @@ export function ProductDetailPage() {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
|
||||
const { data: product, isLoading, error, refetch } = useProduct(id) as {
|
||||
data: Product | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
};
|
||||
const { update, delete: deleteProduct, isUpdating, isDeleting } = useProductMutations();
|
||||
const { product, isLoading, error, refresh } = useProduct(id);
|
||||
const { updateProduct, deleteProduct } = useProducts({ autoFetch: false });
|
||||
const { formatPrice, calculateMargin } = usePricingHelpers();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleUpdate = async (data: UpdateProductDto) => {
|
||||
if (!id) return;
|
||||
await update({ id, data });
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateProduct(id, data);
|
||||
setIsEditing(false);
|
||||
refetch();
|
||||
refresh();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
navigate('/products');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
|
||||
@ -10,7 +10,7 @@ import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useDebounce } from '@hooks/useDebounce';
|
||||
import { useProducts, useProductMutations, useCategoryOptions, usePricingHelpers } from '../hooks';
|
||||
import { useProducts, useCategoryOptions, usePricingHelpers } from '../hooks';
|
||||
import { ProductCard } from '../components';
|
||||
import type { Product, ProductType, ProductSearchParams } from '../types';
|
||||
|
||||
@ -36,7 +36,6 @@ export function ProductsPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<ProductSearchParams>({
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
@ -49,16 +48,20 @@ export function ProductsPage() {
|
||||
const queryParams: ProductSearchParams = {
|
||||
...filters,
|
||||
search: debouncedSearch || undefined,
|
||||
offset: (page - 1) * (filters.limit || 25),
|
||||
page,
|
||||
};
|
||||
|
||||
const { data, isLoading, error, refetch } = useProducts(queryParams);
|
||||
const { delete: deleteProduct, isDeleting } = useProductMutations();
|
||||
|
||||
const products = data?.data || [];
|
||||
const total = data?.total || 0;
|
||||
const {
|
||||
products,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
deleteProduct
|
||||
} = useProducts(queryParams);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const limit = filters.limit || 25;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
setPage(newPage);
|
||||
@ -88,10 +91,11 @@ export function ProductsPage() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!productToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProduct(productToDelete.id);
|
||||
refetch();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteModalOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
@ -284,7 +288,7 @@ export function ProductsPage() {
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export { ProductsPage } from './ProductsPage';
|
||||
export { ProductDetailPage } from './ProductDetailPage';
|
||||
export { CategoriesPage } from './CategoriesPage';
|
||||
export { PricingPage } from './PricingPage';
|
||||
|
||||
@ -44,6 +44,7 @@ export interface Product {
|
||||
createdBy: string | null;
|
||||
updatedAt: string;
|
||||
updatedBy: string | null;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateProductDto {
|
||||
@ -57,7 +58,25 @@ export interface CreateProductDto {
|
||||
price?: number;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
taxIncluded?: boolean;
|
||||
taxRate?: number;
|
||||
taxCode?: string;
|
||||
uom?: string;
|
||||
uomPurchase?: string;
|
||||
uomConversion?: number;
|
||||
trackInventory?: boolean;
|
||||
minStock?: number;
|
||||
maxStock?: number;
|
||||
reorderPoint?: number;
|
||||
leadTimeDays?: number;
|
||||
weight?: number;
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
volume?: number;
|
||||
imageUrl?: string;
|
||||
images?: string[];
|
||||
attributes?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
isSellable?: boolean;
|
||||
isPurchasable?: boolean;
|
||||
@ -74,21 +93,43 @@ export interface UpdateProductDto {
|
||||
price?: number;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
taxIncluded?: boolean;
|
||||
taxRate?: number;
|
||||
taxCode?: string | null;
|
||||
uom?: string;
|
||||
uomPurchase?: string | null;
|
||||
uomConversion?: number;
|
||||
trackInventory?: boolean;
|
||||
minStock?: number;
|
||||
maxStock?: number | null;
|
||||
reorderPoint?: number | null;
|
||||
leadTimeDays?: number;
|
||||
weight?: number | null;
|
||||
length?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
volume?: number | null;
|
||||
imageUrl?: string | null;
|
||||
images?: string[];
|
||||
attributes?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
isSellable?: boolean;
|
||||
isPurchasable?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductSearchParams {
|
||||
export interface ProductFilters {
|
||||
search?: string;
|
||||
categoryId?: string;
|
||||
productType?: ProductType;
|
||||
isActive?: boolean;
|
||||
isSellable?: boolean;
|
||||
isPurchasable?: boolean;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ==================== Category Types ====================
|
||||
@ -108,6 +149,8 @@ export interface ProductCategory {
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
children?: ProductCategory[];
|
||||
}
|
||||
|
||||
export interface CreateCategoryDto {
|
||||
@ -115,6 +158,8 @@ export interface CreateCategoryDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
imageUrl?: string;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@ -123,15 +168,19 @@ export interface UpdateCategoryDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
parentId?: string | null;
|
||||
imageUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CategorySearchParams {
|
||||
export interface CategoryFilters {
|
||||
search?: string;
|
||||
parentId?: string;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ==================== Variant Types ====================
|
||||
@ -155,6 +204,28 @@ export interface ProductVariant {
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
export interface CreateVariantDto {
|
||||
sku: string;
|
||||
name: string;
|
||||
barcode?: string;
|
||||
priceExtra?: number;
|
||||
costExtra?: number;
|
||||
stockQty?: number;
|
||||
imageUrl?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVariantDto {
|
||||
sku?: string;
|
||||
name?: string;
|
||||
barcode?: string | null;
|
||||
priceExtra?: number;
|
||||
costExtra?: number;
|
||||
stockQty?: number;
|
||||
imageUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Price Types ====================
|
||||
|
||||
export type PriceType = 'standard' | 'wholesale' | 'retail' | 'promo';
|
||||
@ -176,7 +247,6 @@ export interface ProductPrice {
|
||||
}
|
||||
|
||||
export interface CreatePriceDto {
|
||||
productId: string;
|
||||
priceType?: PriceType;
|
||||
priceListName?: string;
|
||||
price: number;
|
||||
@ -232,6 +302,42 @@ export interface ProductAttributeValue {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAttributeDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
displayType?: AttributeDisplayType;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAttributeDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
displayType?: AttributeDisplayType;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CreateAttributeValueDto {
|
||||
code?: string;
|
||||
name: string;
|
||||
htmlColor?: string;
|
||||
imageUrl?: string;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAttributeValueDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
htmlColor?: string | null;
|
||||
imageUrl?: string | null;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
// ==================== Supplier Types ====================
|
||||
|
||||
export interface ProductSupplier {
|
||||
@ -251,20 +357,47 @@ export interface ProductSupplier {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSupplierDto {
|
||||
supplierId: string;
|
||||
supplierSku?: string;
|
||||
supplierName?: string;
|
||||
purchasePrice?: number;
|
||||
currency?: string;
|
||||
minOrderQty?: number;
|
||||
leadTimeDays?: number;
|
||||
isPreferred?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSupplierDto {
|
||||
supplierId?: string;
|
||||
supplierSku?: string | null;
|
||||
supplierName?: string | null;
|
||||
purchasePrice?: number | null;
|
||||
currency?: string;
|
||||
minOrderQty?: number;
|
||||
leadTimeDays?: number;
|
||||
isPreferred?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Response Types ====================
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
data: Product[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface CategoriesResponse {
|
||||
data: ProductCategory[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface PricesResponse {
|
||||
@ -272,8 +405,26 @@ export interface PricesResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface VariantsResponse {
|
||||
data: ProductVariant[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AttributesResponse {
|
||||
data: ProductAttribute[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ==================== Tree Node Types ====================
|
||||
|
||||
export interface CategoryTreeNode extends ProductCategory {
|
||||
children: CategoryTreeNode[];
|
||||
}
|
||||
|
||||
// ==================== Backward Compatibility Aliases ====================
|
||||
|
||||
/** @deprecated Use ProductFilters instead */
|
||||
export type ProductSearchParams = ProductFilters;
|
||||
|
||||
/** @deprecated Use CategoryFilters instead */
|
||||
export type CategorySearchParams = CategoryFilters;
|
||||
|
||||
@ -1 +1 @@
|
||||
export { warehousesApi, locationsApi } from './warehouses.api';
|
||||
export { warehousesApi, locationsApi, zonesApi } from './warehouses.api';
|
||||
|
||||
@ -3,10 +3,13 @@ import type { ApiResponse } from '@shared/types/api.types';
|
||||
import type {
|
||||
Warehouse,
|
||||
WarehouseLocation,
|
||||
WarehouseZone,
|
||||
CreateWarehouseDto,
|
||||
UpdateWarehouseDto,
|
||||
CreateLocationDto,
|
||||
UpdateLocationDto,
|
||||
CreateZoneDto,
|
||||
UpdateZoneDto,
|
||||
WarehouseFilters,
|
||||
LocationFilters,
|
||||
WarehousesResponse,
|
||||
@ -159,3 +162,63 @@ export const locationsApi = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Zones API ====================
|
||||
|
||||
export const zonesApi = {
|
||||
// List all zones for a warehouse
|
||||
getByWarehouse: async (warehouseId: string): Promise<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');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -15,3 +15,10 @@ export {
|
||||
useLocationTree,
|
||||
} from './useLocations';
|
||||
export type { UseLocationsOptions } from './useLocations';
|
||||
|
||||
// Zones hooks
|
||||
export {
|
||||
useZones,
|
||||
useZone,
|
||||
} from './useZones';
|
||||
export type { UseZonesOptions } from './useZones';
|
||||
|
||||
169
src/features/warehouses/hooks/useZones.ts
Normal file
169
src/features/warehouses/hooks/useZones.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// API
|
||||
export { warehousesApi, locationsApi } from './api';
|
||||
export { warehousesApi, locationsApi, zonesApi } from './api';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
@ -35,8 +35,10 @@ export {
|
||||
useAllLocations,
|
||||
useLocation,
|
||||
useLocationTree,
|
||||
useZones,
|
||||
useZone,
|
||||
} from './hooks';
|
||||
export type { UseWarehousesOptions, UseLocationsOptions } from './hooks';
|
||||
export type { UseWarehousesOptions, UseLocationsOptions, UseZonesOptions } from './hooks';
|
||||
|
||||
// Components
|
||||
export {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user