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