feat(catalogs): Add complete catalog frontend module (MGN-005)
- Types: Country, State, Currency, CurrencyRate, Uom, UomCategory, PaymentTerm, DiscountRule - API clients: countries.api, currencies.api, uom.api - Hooks: useCountries, useStates, useCurrencies, useCurrencyRates, useLatestRates, useCurrencyConversion, useUomCategories, useUom, useUomConversion, useConversionTable - Components: CountrySelect, StateSelect, CurrencySelect, CurrencyInput, UomCategorySelect, UomSelect, UomQuantityInput, AddressInput, ConversionTableDisplay, CurrencyRatesDisplay Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
28b27565f8
commit
94ba9f6bcd
52
src/features/catalogs/api/countries.api.ts
Normal file
52
src/features/catalogs/api/countries.api.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type { Country, State, CreateStateDto, UpdateStateDto } from '../types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/core';
|
||||||
|
|
||||||
|
export const countriesApi = {
|
||||||
|
// Countries
|
||||||
|
getAll: async (): Promise<Country[]> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/countries`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Country> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/countries/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// States
|
||||||
|
getStates: async (params?: { countryId?: string; countryCode?: string; active?: boolean }): Promise<State[]> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/states`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatesByCountry: async (countryId: string): Promise<State[]> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/countries/${countryId}/states`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatesByCountryCode: async (countryCode: string): Promise<State[]> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/countries/code/${countryCode}/states`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStateById: async (id: string): Promise<State> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/states/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createState: async (data: CreateStateDto): Promise<State> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/states`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateState: async (id: string, data: UpdateStateDto): Promise<State> => {
|
||||||
|
const response = await axios.put(`${API_BASE}/states/${id}`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteState: async (id: string): Promise<void> => {
|
||||||
|
await axios.delete(`${API_BASE}/states/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
93
src/features/catalogs/api/currencies.api.ts
Normal file
93
src/features/catalogs/api/currencies.api.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
Currency,
|
||||||
|
CreateCurrencyDto,
|
||||||
|
UpdateCurrencyDto,
|
||||||
|
CurrencyRate,
|
||||||
|
CreateCurrencyRateDto,
|
||||||
|
CurrencyConversion,
|
||||||
|
LatestRates,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/core';
|
||||||
|
|
||||||
|
export const currenciesApi = {
|
||||||
|
// Currencies
|
||||||
|
getAll: async (activeOnly?: boolean): Promise<Currency[]> => {
|
||||||
|
const params = activeOnly ? { active: 'true' } : {};
|
||||||
|
const response = await axios.get(`${API_BASE}/currencies`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Currency> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/currencies/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateCurrencyDto): Promise<Currency> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/currencies`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateCurrencyDto): Promise<Currency> => {
|
||||||
|
const response = await axios.put(`${API_BASE}/currencies/${id}`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Currency Rates
|
||||||
|
getRates: async (params?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<CurrencyRate[]> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/currency-rates`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRate: async (from: string, to: string, date?: string): Promise<{
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
rate: number;
|
||||||
|
date: string;
|
||||||
|
}> => {
|
||||||
|
const params = date ? { date } : {};
|
||||||
|
const response = await axios.get(`${API_BASE}/currency-rates/rate/${from}/${to}`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getLatestRates: async (baseCurrency?: string): Promise<LatestRates> => {
|
||||||
|
const params = baseCurrency ? { base: baseCurrency } : {};
|
||||||
|
const response = await axios.get(`${API_BASE}/currency-rates/latest`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRateHistory: async (from: string, to: string, days?: number): Promise<CurrencyRate[]> => {
|
||||||
|
const params = days ? { days } : {};
|
||||||
|
const response = await axios.get(`${API_BASE}/currency-rates/history/${from}/${to}`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createRate: async (data: CreateCurrencyRateDto): Promise<CurrencyRate> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/currency-rates`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
convert: async (
|
||||||
|
amount: number,
|
||||||
|
fromCurrencyCode: string,
|
||||||
|
toCurrencyCode: string,
|
||||||
|
date?: string
|
||||||
|
): Promise<CurrencyConversion> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/currency-rates/convert`, {
|
||||||
|
amount,
|
||||||
|
fromCurrencyCode,
|
||||||
|
toCurrencyCode,
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRate: async (id: string): Promise<void> => {
|
||||||
|
await axios.delete(`${API_BASE}/currency-rates/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/features/catalogs/api/index.ts
Normal file
3
src/features/catalogs/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { countriesApi } from './countries.api';
|
||||||
|
export { currenciesApi } from './currencies.api';
|
||||||
|
export { uomApi } from './uom.api';
|
||||||
81
src/features/catalogs/api/uom.api.ts
Normal file
81
src/features/catalogs/api/uom.api.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import type {
|
||||||
|
UomCategory,
|
||||||
|
Uom,
|
||||||
|
CreateUomDto,
|
||||||
|
UpdateUomDto,
|
||||||
|
UomConversion,
|
||||||
|
ConversionTable,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/core';
|
||||||
|
|
||||||
|
export const uomApi = {
|
||||||
|
// UoM Categories
|
||||||
|
getCategories: async (activeOnly?: boolean): Promise<UomCategory[]> => {
|
||||||
|
const params = activeOnly ? { active: 'true' } : {};
|
||||||
|
const response = await axios.get(`${API_BASE}/uom-categories`, { params });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCategoryById: async (id: string): Promise<UomCategory> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/uom-categories/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// UoM
|
||||||
|
getAll: async (params?: { categoryId?: string; active?: boolean }): Promise<Uom[]> => {
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
if (params?.categoryId) queryParams.category_id = params.categoryId;
|
||||||
|
if (params?.active) queryParams.active = 'true';
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_BASE}/uom`, { params: queryParams });
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Uom> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/uom/${id}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getByCode: async (code: string): Promise<Uom | null> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE}/uom/by-code/${code}`);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateUomDto): Promise<Uom> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/uom`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateUomDto): Promise<Uom> => {
|
||||||
|
const response = await axios.put(`${API_BASE}/uom/${id}`, data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conversions
|
||||||
|
convert: async (
|
||||||
|
quantity: number,
|
||||||
|
fromUomId: string,
|
||||||
|
toUomId: string
|
||||||
|
): Promise<UomConversion> => {
|
||||||
|
const response = await axios.post(`${API_BASE}/uom/convert`, {
|
||||||
|
quantity,
|
||||||
|
fromUomId,
|
||||||
|
toUomId,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getConversionTable: async (categoryId: string): Promise<ConversionTable> => {
|
||||||
|
const response = await axios.get(`${API_BASE}/uom-categories/${categoryId}/conversions`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
301
src/features/catalogs/components/AddressInput.tsx
Normal file
301
src/features/catalogs/components/AddressInput.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { CountrySelect } from './CountrySelect';
|
||||||
|
import { StateSelect } from './StateSelect';
|
||||||
|
import type { Country, State } from '../types';
|
||||||
|
|
||||||
|
export interface AddressData {
|
||||||
|
street?: string;
|
||||||
|
exteriorNumber?: string;
|
||||||
|
interiorNumber?: string;
|
||||||
|
neighborhood?: string;
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
countryId?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
stateId?: string;
|
||||||
|
stateCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddressInputProps {
|
||||||
|
value?: AddressData;
|
||||||
|
onChange: (address: AddressData) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
errors?: Partial<Record<keyof AddressData, string>>;
|
||||||
|
showLabels?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddressInput: React.FC<AddressInputProps> = ({
|
||||||
|
value = {},
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
errors = {},
|
||||||
|
showLabels = true,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(field: keyof AddressData, fieldValue: string | undefined) => {
|
||||||
|
onChange({ ...value, [field]: fieldValue });
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCountryChange = useCallback(
|
||||||
|
(country: Country | null) => {
|
||||||
|
setSelectedCountry(country);
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
countryId: country?.id,
|
||||||
|
countryCode: country?.code,
|
||||||
|
stateId: undefined,
|
||||||
|
stateCode: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStateChange = useCallback(
|
||||||
|
(state: State | null) => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
stateId: state?.id,
|
||||||
|
stateCode: state?.code,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const getInputClass = (field: keyof AddressData) => {
|
||||||
|
return `${inputClasses} ${errors[field] ? 'border-red-300' : 'border-gray-300'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLabel = (text: string, isRequired: boolean = false) => {
|
||||||
|
if (!showLabels) return null;
|
||||||
|
return (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{text}
|
||||||
|
{isRequired && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderError = (field: keyof AddressData) => {
|
||||||
|
if (!errors[field]) return null;
|
||||||
|
return <p className="mt-1 text-sm text-red-600">{errors[field]}</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${className}`}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
{renderLabel('Calle', required)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.street || ''}
|
||||||
|
onChange={(e) => handleFieldChange('street', e.target.value)}
|
||||||
|
placeholder="Calle"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('street')}
|
||||||
|
/>
|
||||||
|
{renderError('street')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
{renderLabel('No. Ext')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.exteriorNumber || ''}
|
||||||
|
onChange={(e) => handleFieldChange('exteriorNumber', e.target.value)}
|
||||||
|
placeholder="Ext"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('exteriorNumber')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('No. Int')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.interiorNumber || ''}
|
||||||
|
onChange={(e) => handleFieldChange('interiorNumber', e.target.value)}
|
||||||
|
placeholder="Int"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('interiorNumber')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
{renderLabel('Colonia')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.neighborhood || ''}
|
||||||
|
onChange={(e) => handleFieldChange('neighborhood', e.target.value)}
|
||||||
|
placeholder="Colonia"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('neighborhood')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('Ciudad', required)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.city || ''}
|
||||||
|
onChange={(e) => handleFieldChange('city', e.target.value)}
|
||||||
|
placeholder="Ciudad"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('city')}
|
||||||
|
/>
|
||||||
|
{renderError('city')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('C.P.')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.postalCode || ''}
|
||||||
|
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
|
||||||
|
placeholder="C.P."
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('postalCode')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<CountrySelect
|
||||||
|
value={value.countryId}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
error={errors.countryId}
|
||||||
|
label={showLabels ? 'País' : undefined}
|
||||||
|
/>
|
||||||
|
<StateSelect
|
||||||
|
value={value.stateId}
|
||||||
|
onChange={handleStateChange}
|
||||||
|
countryId={selectedCountry?.id || value.countryId}
|
||||||
|
disabled={disabled}
|
||||||
|
error={errors.stateId}
|
||||||
|
label={showLabels ? 'Estado' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
<div>
|
||||||
|
{renderLabel('Calle', required)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.street || ''}
|
||||||
|
onChange={(e) => handleFieldChange('street', e.target.value)}
|
||||||
|
placeholder="Nombre de la calle"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('street')}
|
||||||
|
/>
|
||||||
|
{renderError('street')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
{renderLabel('Número Exterior')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.exteriorNumber || ''}
|
||||||
|
onChange={(e) => handleFieldChange('exteriorNumber', e.target.value)}
|
||||||
|
placeholder="No. Ext"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('exteriorNumber')}
|
||||||
|
/>
|
||||||
|
{renderError('exteriorNumber')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('Número Interior')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.interiorNumber || ''}
|
||||||
|
onChange={(e) => handleFieldChange('interiorNumber', e.target.value)}
|
||||||
|
placeholder="No. Int (opcional)"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('interiorNumber')}
|
||||||
|
/>
|
||||||
|
{renderError('interiorNumber')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('Código Postal')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.postalCode || ''}
|
||||||
|
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
|
||||||
|
placeholder="C.P."
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('postalCode')}
|
||||||
|
/>
|
||||||
|
{renderError('postalCode')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
{renderLabel('Colonia')}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.neighborhood || ''}
|
||||||
|
onChange={(e) => handleFieldChange('neighborhood', e.target.value)}
|
||||||
|
placeholder="Colonia o fraccionamiento"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('neighborhood')}
|
||||||
|
/>
|
||||||
|
{renderError('neighborhood')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{renderLabel('Ciudad', required)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.city || ''}
|
||||||
|
onChange={(e) => handleFieldChange('city', e.target.value)}
|
||||||
|
placeholder="Ciudad o municipio"
|
||||||
|
disabled={disabled}
|
||||||
|
className={getInputClass('city')}
|
||||||
|
/>
|
||||||
|
{renderError('city')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<CountrySelect
|
||||||
|
value={value.countryId}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
error={errors.countryId}
|
||||||
|
label={showLabels ? 'País' : undefined}
|
||||||
|
/>
|
||||||
|
<StateSelect
|
||||||
|
value={value.stateId}
|
||||||
|
onChange={handleStateChange}
|
||||||
|
countryId={selectedCountry?.id || value.countryId}
|
||||||
|
disabled={disabled}
|
||||||
|
error={errors.stateId}
|
||||||
|
label={showLabels ? 'Estado / Provincia' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
144
src/features/catalogs/components/ConversionTableDisplay.tsx
Normal file
144
src/features/catalogs/components/ConversionTableDisplay.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useConversionTable } from '../hooks';
|
||||||
|
|
||||||
|
interface ConversionTableDisplayProps {
|
||||||
|
categoryId: string | null;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversionTableDisplay: React.FC<ConversionTableDisplayProps> = ({
|
||||||
|
categoryId,
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const { table, isLoading, error } = useConversionTable(categoryId);
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
|
||||||
|
<p className="text-sm text-gray-500">Seleccione una categoría para ver las conversiones</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-gray-50 p-4 ${className}`}>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
<span className="ml-2 text-sm text-gray-500">Cargando conversiones...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-red-50 p-4 ${className}`}>
|
||||||
|
<p className="text-sm text-red-600">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!table || table.units.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
|
||||||
|
<p className="text-sm text-gray-500">No hay unidades en esta categoría</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { categoryName, referenceUnit, units, conversions } = table;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-200 p-3 ${className}`}>
|
||||||
|
<p className="mb-2 text-xs font-medium text-gray-600">{categoryName}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{conversions.slice(0, 5).map((conv, idx) => (
|
||||||
|
<div key={idx} className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
1 {conv.fromCode} →
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{conv.factor.toFixed(4)} {conv.toCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{conversions.length > 5 && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
+{conversions.length - 5} más...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-200 ${className}`}>
|
||||||
|
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">{categoryName}</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Unidad de referencia: <span className="font-medium">{referenceUnit}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{units.map((unit) => (
|
||||||
|
<span
|
||||||
|
key={unit.id}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||||
|
${unit.isReference
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{unit.code}
|
||||||
|
{unit.isReference && (
|
||||||
|
<span className="ml-1 text-blue-600">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
|
||||||
|
De
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
|
||||||
|
A
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">
|
||||||
|
Factor
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{conversions.map((conv, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 font-medium text-gray-900">
|
||||||
|
1 {conv.fromCode}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-gray-600">
|
||||||
|
{conv.toCode}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-right font-mono text-gray-900">
|
||||||
|
{conv.factor.toFixed(6)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
src/features/catalogs/components/CountrySelect.tsx
Normal file
79
src/features/catalogs/components/CountrySelect.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useCountries } from '../hooks';
|
||||||
|
import type { Country } from '../types';
|
||||||
|
|
||||||
|
interface CountrySelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (country: Country | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CountrySelect: React.FC<CountrySelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Seleccionar país',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
const { countries, isLoading, error: loadError } = useCountries();
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const country = countries.find((c) => c.id === selectedId);
|
||||||
|
onChange(country || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error || loadError
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
required={required}
|
||||||
|
className={`${baseClasses} ${errorClasses}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isLoading ? 'Cargando...' : placeholder}
|
||||||
|
</option>
|
||||||
|
{countries.map((country) => (
|
||||||
|
<option key={country.id} value={country.id}>
|
||||||
|
{country.name} ({country.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(error || loadError) && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error || loadError?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
139
src/features/catalogs/components/CurrencyInput.tsx
Normal file
139
src/features/catalogs/components/CurrencyInput.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useCurrencies, useCurrencyConversion } from '../hooks';
|
||||||
|
import type { Currency, CurrencyConversion } from '../types';
|
||||||
|
|
||||||
|
interface CurrencyInputProps {
|
||||||
|
value?: number;
|
||||||
|
onChange: (amount: number) => void;
|
||||||
|
currencyId?: string;
|
||||||
|
baseCurrencyId?: string;
|
||||||
|
showConversion?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
currencyId,
|
||||||
|
baseCurrencyId,
|
||||||
|
showConversion = false,
|
||||||
|
placeholder = '0.00',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
min = 0,
|
||||||
|
max,
|
||||||
|
step = 0.01,
|
||||||
|
}) => {
|
||||||
|
const { currencies } = useCurrencies();
|
||||||
|
const { convert, isLoading: isConverting } = useCurrencyConversion();
|
||||||
|
const [conversion, setConversion] = useState<CurrencyConversion | null>(null);
|
||||||
|
|
||||||
|
const selectedCurrency = currencies.find((c) => c.id === currencyId);
|
||||||
|
const baseCurrency = currencies.find((c) => c.id === baseCurrencyId);
|
||||||
|
|
||||||
|
const fetchConversion = useCallback(async () => {
|
||||||
|
if (!showConversion || !value || !currencyId || !baseCurrencyId || currencyId === baseCurrencyId) {
|
||||||
|
setConversion(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCurrency = currencies.find((c) => c.id === currencyId);
|
||||||
|
const toCurrency = currencies.find((c) => c.id === baseCurrencyId);
|
||||||
|
if (!fromCurrency || !toCurrency) return;
|
||||||
|
|
||||||
|
const result = await convert(value, fromCurrency.code, toCurrency.code);
|
||||||
|
setConversion(result);
|
||||||
|
}, [showConversion, value, currencyId, baseCurrencyId, currencies, convert]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(fetchConversion, 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [fetchConversion]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency?: Currency) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency?.code || 'MXN',
|
||||||
|
minimumFractionDigits: currency?.decimals ?? 2,
|
||||||
|
maximumFractionDigits: currency?.decimals ?? 2,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm text-right
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{selectedCurrency?.symbol && (
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">
|
||||||
|
{selectedCurrency.symbol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
className={`${baseClasses} ${errorClasses} ${selectedCurrency?.symbol ? 'pl-8' : ''}`}
|
||||||
|
/>
|
||||||
|
{selectedCurrency && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">
|
||||||
|
{selectedCurrency.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showConversion && conversion && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{isConverting ? (
|
||||||
|
'Calculando...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
≈ {formatAmount(conversion.convertedAmount, baseCurrency)}
|
||||||
|
<span className="ml-1 text-gray-400">
|
||||||
|
(TC: {conversion.rate.toFixed(4)})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
src/features/catalogs/components/CurrencyRatesDisplay.tsx
Normal file
127
src/features/catalogs/components/CurrencyRatesDisplay.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useLatestRates } from '../hooks';
|
||||||
|
|
||||||
|
interface CurrencyRatesDisplayProps {
|
||||||
|
baseCurrency?: string;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrencyRatesDisplay: React.FC<CurrencyRatesDisplayProps> = ({
|
||||||
|
baseCurrency = 'MXN',
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const { data, isLoading, error, refresh } = useLatestRates(baseCurrency);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-gray-50 p-4 ${className}`}>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
<span className="ml-2 text-sm text-gray-500">Cargando tipos de cambio...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-red-50 p-4 ${className}`}>
|
||||||
|
<p className="text-sm text-red-600">{error.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="mt-2 text-sm text-red-700 underline hover:no-underline"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || Object.keys(data.rates).length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg bg-gray-50 p-4 text-center ${className}`}>
|
||||||
|
<p className="text-sm text-gray-500">No hay tipos de cambio disponibles</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rates = Object.entries(data.rates);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-200 p-3 ${className}`}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-gray-600">
|
||||||
|
Tipos de Cambio ({baseCurrency})
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatDate(data.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{rates.slice(0, 4).map(([currency, rate]) => (
|
||||||
|
<div key={currency} className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-600">{currency}</span>
|
||||||
|
<span className="font-medium text-gray-900">{rate.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rates.length > 4 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-400">+{rates.length - 4} más</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-200 ${className}`}>
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Tipos de Cambio</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Base: <span className="font-medium">{baseCurrency}</span> |
|
||||||
|
Fecha: <span className="font-medium">{formatDate(data.date)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rates.map(([currency, rate]) => (
|
||||||
|
<div
|
||||||
|
key={currency}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{currency}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="font-mono text-sm font-semibold text-gray-900">
|
||||||
|
{rate.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
1 {currency} = {(1 / rate).toFixed(4)} {baseCurrency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
src/features/catalogs/components/CurrencySelect.tsx
Normal file
84
src/features/catalogs/components/CurrencySelect.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useCurrencies } from '../hooks';
|
||||||
|
import type { Currency } from '../types';
|
||||||
|
|
||||||
|
interface CurrencySelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (currency: Currency | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
showSymbol?: boolean;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrencySelect: React.FC<CurrencySelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Seleccionar moneda',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
showSymbol = true,
|
||||||
|
activeOnly = true,
|
||||||
|
}) => {
|
||||||
|
const { currencies, isLoading, error: loadError } = useCurrencies(activeOnly);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currency = currencies.find((c) => c.id === selectedId);
|
||||||
|
onChange(currency || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error || loadError
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
required={required}
|
||||||
|
className={`${baseClasses} ${errorClasses}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isLoading ? 'Cargando...' : placeholder}
|
||||||
|
</option>
|
||||||
|
{currencies.map((currency) => (
|
||||||
|
<option key={currency.id} value={currency.id}>
|
||||||
|
{currency.code} - {currency.name}
|
||||||
|
{showSymbol && currency.symbol && ` (${currency.symbol})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(error || loadError) && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error || loadError?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
src/features/catalogs/components/StateSelect.tsx
Normal file
89
src/features/catalogs/components/StateSelect.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useStates } from '../hooks';
|
||||||
|
import type { State } from '../types';
|
||||||
|
|
||||||
|
interface StateSelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (state: State | null) => void;
|
||||||
|
countryId?: string;
|
||||||
|
countryCode?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StateSelect: React.FC<StateSelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
countryId,
|
||||||
|
countryCode,
|
||||||
|
placeholder = 'Seleccionar estado',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
const { states, isLoading, error: loadError } = useStates(countryId, countryCode);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = states.find((s) => s.id === selectedId);
|
||||||
|
onChange(state || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCountryFilter = countryId || countryCode;
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error || loadError
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || isLoading || !hasCountryFilter}
|
||||||
|
required={required}
|
||||||
|
className={`${baseClasses} ${errorClasses}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{!hasCountryFilter
|
||||||
|
? 'Seleccione un país primero'
|
||||||
|
: isLoading
|
||||||
|
? 'Cargando...'
|
||||||
|
: placeholder}
|
||||||
|
</option>
|
||||||
|
{states.map((state) => (
|
||||||
|
<option key={state.id} value={state.id}>
|
||||||
|
{state.name} ({state.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(error || loadError) && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error || loadError?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
src/features/catalogs/components/UomCategorySelect.tsx
Normal file
79
src/features/catalogs/components/UomCategorySelect.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useUomCategories } from '../hooks';
|
||||||
|
import type { UomCategory } from '../types';
|
||||||
|
|
||||||
|
interface UomCategorySelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (category: UomCategory | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UomCategorySelect: React.FC<UomCategorySelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Seleccionar categoría',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
const { categories, isLoading, error: loadError } = useUomCategories();
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = categories.find((c) => c.id === selectedId);
|
||||||
|
onChange(category || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error || loadError
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
required={required}
|
||||||
|
className={`${baseClasses} ${errorClasses}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isLoading ? 'Cargando...' : placeholder}
|
||||||
|
</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(error || loadError) && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error || loadError?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
128
src/features/catalogs/components/UomQuantityInput.tsx
Normal file
128
src/features/catalogs/components/UomQuantityInput.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useUom, useUomConversion } from '../hooks';
|
||||||
|
import type { Uom, UomConversion } from '../types';
|
||||||
|
|
||||||
|
interface UomQuantityInputProps {
|
||||||
|
value?: number;
|
||||||
|
onChange: (quantity: number) => void;
|
||||||
|
uomId?: string;
|
||||||
|
targetUomId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
showConversion?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UomQuantityInput: React.FC<UomQuantityInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
uomId,
|
||||||
|
targetUomId,
|
||||||
|
categoryId,
|
||||||
|
showConversion = false,
|
||||||
|
placeholder = '0',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
min = 0,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
}) => {
|
||||||
|
const { uoms } = useUom(categoryId);
|
||||||
|
const { convert, isLoading: isConverting } = useUomConversion();
|
||||||
|
const [conversion, setConversion] = useState<UomConversion | null>(null);
|
||||||
|
|
||||||
|
const selectedUom = uoms.find((u) => u.id === uomId);
|
||||||
|
const targetUom = uoms.find((u) => u.id === targetUomId);
|
||||||
|
|
||||||
|
const fetchConversion = useCallback(async () => {
|
||||||
|
if (!showConversion || !value || !uomId || !targetUomId || uomId === targetUomId) {
|
||||||
|
setConversion(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await convert(value, uomId, targetUomId);
|
||||||
|
setConversion(result);
|
||||||
|
}, [showConversion, value, uomId, targetUomId, convert]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(fetchConversion, 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [fetchConversion]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatQuantity = (qty: number, uom?: Uom) => {
|
||||||
|
const decimals = uom?.code?.includes('kg') || uom?.code?.includes('l') ? 3 : 2;
|
||||||
|
return `${qty.toFixed(decimals)} ${uom?.code || ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm text-right
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
className={`${baseClasses} ${errorClasses} pr-16`}
|
||||||
|
/>
|
||||||
|
{selectedUom && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs">
|
||||||
|
{selectedUom.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showConversion && conversion && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{isConverting ? (
|
||||||
|
'Calculando...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
= {formatQuantity(conversion.convertedQuantity, targetUom)}
|
||||||
|
<span className="ml-1 text-gray-400">
|
||||||
|
(factor: {conversion.factor.toFixed(6)})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
src/features/catalogs/components/UomSelect.tsx
Normal file
83
src/features/catalogs/components/UomSelect.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useUom } from '../hooks';
|
||||||
|
import type { Uom } from '../types';
|
||||||
|
|
||||||
|
interface UomSelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (uom: Uom | null) => void;
|
||||||
|
categoryId?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UomSelect: React.FC<UomSelectProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
categoryId,
|
||||||
|
placeholder = 'Seleccionar unidad',
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
className = '',
|
||||||
|
error,
|
||||||
|
label,
|
||||||
|
activeOnly = true,
|
||||||
|
}) => {
|
||||||
|
const { uoms, isLoading, error: loadError } = useUom(categoryId, activeOnly);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedId = e.target.value;
|
||||||
|
if (!selectedId) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uom = uoms.find((u) => u.id === selectedId);
|
||||||
|
onChange(uom || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
w-full rounded-md border px-3 py-2 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||||
|
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const errorClasses = error || loadError
|
||||||
|
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={value || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
required={required}
|
||||||
|
className={`${baseClasses} ${errorClasses}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{isLoading ? 'Cargando...' : placeholder}
|
||||||
|
</option>
|
||||||
|
{uoms.map((uom) => (
|
||||||
|
<option key={uom.id} value={uom.id}>
|
||||||
|
{uom.name} ({uom.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(error || loadError) && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error || loadError?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/features/catalogs/components/index.ts
Normal file
16
src/features/catalogs/components/index.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Select components
|
||||||
|
export { CountrySelect } from './CountrySelect';
|
||||||
|
export { StateSelect } from './StateSelect';
|
||||||
|
export { CurrencySelect } from './CurrencySelect';
|
||||||
|
export { UomCategorySelect } from './UomCategorySelect';
|
||||||
|
export { UomSelect } from './UomSelect';
|
||||||
|
|
||||||
|
// Input components
|
||||||
|
export { CurrencyInput } from './CurrencyInput';
|
||||||
|
export { UomQuantityInput } from './UomQuantityInput';
|
||||||
|
export { AddressInput } from './AddressInput';
|
||||||
|
export type { AddressData } from './AddressInput';
|
||||||
|
|
||||||
|
// Display components
|
||||||
|
export { ConversionTableDisplay } from './ConversionTableDisplay';
|
||||||
|
export { CurrencyRatesDisplay } from './CurrencyRatesDisplay';
|
||||||
3
src/features/catalogs/hooks/index.ts
Normal file
3
src/features/catalogs/hooks/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { useCountries, useCountry, useStates } from './useCountries';
|
||||||
|
export { useCurrencies, useCurrencyRates, useLatestRates, useCurrencyConversion } from './useCurrencies';
|
||||||
|
export { useUomCategories, useUom, useUomConversion, useConversionTable } from './useUom';
|
||||||
120
src/features/catalogs/hooks/useCountries.ts
Normal file
120
src/features/catalogs/hooks/useCountries.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { countriesApi } from '../api';
|
||||||
|
import type { Country, State, CreateStateDto, UpdateStateDto } from '../types';
|
||||||
|
|
||||||
|
export function useCountries() {
|
||||||
|
const [countries, setCountries] = useState<Country[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchCountries = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await countriesApi.getAll();
|
||||||
|
setCountries(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar países'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCountries();
|
||||||
|
}, [fetchCountries]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
countries,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchCountries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCountry(id: string | null) {
|
||||||
|
const [country, setCountry] = useState<Country | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) {
|
||||||
|
setCountry(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await countriesApi.getById(id);
|
||||||
|
setCountry(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar país'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return { country, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStates(countryId?: string, countryCode?: string) {
|
||||||
|
const [states, setStates] = useState<State[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchStates = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let data: State[];
|
||||||
|
if (countryId) {
|
||||||
|
data = await countriesApi.getStatesByCountry(countryId);
|
||||||
|
} else if (countryCode) {
|
||||||
|
data = await countriesApi.getStatesByCountryCode(countryCode);
|
||||||
|
} else {
|
||||||
|
data = await countriesApi.getStates({ active: true });
|
||||||
|
}
|
||||||
|
setStates(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar estados'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [countryId, countryCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStates();
|
||||||
|
}, [fetchStates]);
|
||||||
|
|
||||||
|
const createState = useCallback(async (data: CreateStateDto): Promise<State> => {
|
||||||
|
const newState = await countriesApi.createState(data);
|
||||||
|
setStates(prev => [...prev, newState]);
|
||||||
|
return newState;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateState = useCallback(async (id: string, data: UpdateStateDto): Promise<State> => {
|
||||||
|
const updated = await countriesApi.updateState(id, data);
|
||||||
|
setStates(prev => prev.map(s => s.id === id ? updated : s));
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteState = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await countriesApi.deleteState(id);
|
||||||
|
setStates(prev => prev.filter(s => s.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
states,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchStates,
|
||||||
|
createState,
|
||||||
|
updateState,
|
||||||
|
deleteState,
|
||||||
|
};
|
||||||
|
}
|
||||||
158
src/features/catalogs/hooks/useCurrencies.ts
Normal file
158
src/features/catalogs/hooks/useCurrencies.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { currenciesApi } from '../api';
|
||||||
|
import type {
|
||||||
|
Currency,
|
||||||
|
CreateCurrencyDto,
|
||||||
|
UpdateCurrencyDto,
|
||||||
|
CurrencyRate,
|
||||||
|
CreateCurrencyRateDto,
|
||||||
|
CurrencyConversion,
|
||||||
|
LatestRates,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export function useCurrencies(activeOnly = true) {
|
||||||
|
const [currencies, setCurrencies] = useState<Currency[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchCurrencies = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await currenciesApi.getAll(activeOnly);
|
||||||
|
setCurrencies(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar monedas'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCurrencies();
|
||||||
|
}, [fetchCurrencies]);
|
||||||
|
|
||||||
|
const createCurrency = useCallback(async (data: CreateCurrencyDto): Promise<Currency> => {
|
||||||
|
const newCurrency = await currenciesApi.create(data);
|
||||||
|
setCurrencies(prev => [...prev, newCurrency]);
|
||||||
|
return newCurrency;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCurrency = useCallback(async (id: string, data: UpdateCurrencyDto): Promise<Currency> => {
|
||||||
|
const updated = await currenciesApi.update(id, data);
|
||||||
|
setCurrencies(prev => prev.map(c => c.id === id ? updated : c));
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencies,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchCurrencies,
|
||||||
|
createCurrency,
|
||||||
|
updateCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrencyRates(from?: string, to?: string) {
|
||||||
|
const [rates, setRates] = useState<CurrencyRate[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchRates = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await currenciesApi.getRates({ from, to });
|
||||||
|
setRates(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar tipos de cambio'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [from, to]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRates();
|
||||||
|
}, [fetchRates]);
|
||||||
|
|
||||||
|
const createRate = useCallback(async (data: CreateCurrencyRateDto): Promise<CurrencyRate> => {
|
||||||
|
const newRate = await currenciesApi.createRate(data);
|
||||||
|
setRates(prev => [newRate, ...prev]);
|
||||||
|
return newRate;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteRate = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await currenciesApi.deleteRate(id);
|
||||||
|
setRates(prev => prev.filter(r => r.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rates,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchRates,
|
||||||
|
createRate,
|
||||||
|
deleteRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLatestRates(baseCurrency = 'MXN') {
|
||||||
|
const [data, setData] = useState<LatestRates | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchRates = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await currenciesApi.getLatestRates(baseCurrency);
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar tipos de cambio'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [baseCurrency]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRates();
|
||||||
|
}, [fetchRates]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchRates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrencyConversion() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const convert = useCallback(async (
|
||||||
|
amount: number,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
date?: string
|
||||||
|
): Promise<CurrencyConversion | null> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await currenciesApi.convert(amount, from, to, date);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error en conversión'));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
convert,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
150
src/features/catalogs/hooks/useUom.ts
Normal file
150
src/features/catalogs/hooks/useUom.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { uomApi } from '../api';
|
||||||
|
import type {
|
||||||
|
UomCategory,
|
||||||
|
Uom,
|
||||||
|
CreateUomDto,
|
||||||
|
UpdateUomDto,
|
||||||
|
UomConversion,
|
||||||
|
ConversionTable,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export function useUomCategories() {
|
||||||
|
const [categories, setCategories] = useState<UomCategory[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchCategories = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await uomApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar categorías de UdM'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, [fetchCategories]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchCategories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUom(categoryId?: string, activeOnly = true) {
|
||||||
|
const [uoms, setUoms] = useState<Uom[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchUoms = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await uomApi.getAll({
|
||||||
|
categoryId,
|
||||||
|
active: activeOnly,
|
||||||
|
});
|
||||||
|
setUoms(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar unidades de medida'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [categoryId, activeOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUoms();
|
||||||
|
}, [fetchUoms]);
|
||||||
|
|
||||||
|
const createUom = useCallback(async (data: CreateUomDto): Promise<Uom> => {
|
||||||
|
const newUom = await uomApi.create(data);
|
||||||
|
setUoms(prev => [...prev, newUom]);
|
||||||
|
return newUom;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUom = useCallback(async (id: string, data: UpdateUomDto): Promise<Uom> => {
|
||||||
|
const updated = await uomApi.update(id, data);
|
||||||
|
setUoms(prev => prev.map(u => u.id === id ? updated : u));
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uoms,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchUoms,
|
||||||
|
createUom,
|
||||||
|
updateUom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUomConversion() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const convert = useCallback(async (
|
||||||
|
quantity: number,
|
||||||
|
fromUomId: string,
|
||||||
|
toUomId: string
|
||||||
|
): Promise<UomConversion | null> => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await uomApi.convert(quantity, fromUomId, toUomId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error en conversión de UdM'));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
convert,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversionTable(categoryId: string | null) {
|
||||||
|
const [table, setTable] = useState<ConversionTable | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categoryId) {
|
||||||
|
setTable(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await uomApi.getConversionTable(categoryId);
|
||||||
|
setTable(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error al cargar tabla de conversiones'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch();
|
||||||
|
}, [categoryId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
table,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
src/features/catalogs/index.ts
Normal file
36
src/features/catalogs/index.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// API
|
||||||
|
export { countriesApi, currenciesApi, uomApi } from './api';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export {
|
||||||
|
useCountries,
|
||||||
|
useCountry,
|
||||||
|
useStates,
|
||||||
|
useCurrencies,
|
||||||
|
useCurrencyRates,
|
||||||
|
useLatestRates,
|
||||||
|
useCurrencyConversion,
|
||||||
|
useUomCategories,
|
||||||
|
useUom,
|
||||||
|
useUomConversion,
|
||||||
|
useConversionTable,
|
||||||
|
} from './hooks';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export {
|
||||||
|
CountrySelect,
|
||||||
|
StateSelect,
|
||||||
|
CurrencySelect,
|
||||||
|
UomCategorySelect,
|
||||||
|
UomSelect,
|
||||||
|
CurrencyInput,
|
||||||
|
UomQuantityInput,
|
||||||
|
AddressInput,
|
||||||
|
ConversionTableDisplay,
|
||||||
|
CurrencyRatesDisplay,
|
||||||
|
} from './components';
|
||||||
|
|
||||||
|
export type { AddressData } from './components';
|
||||||
275
src/features/catalogs/types/index.ts
Normal file
275
src/features/catalogs/types/index.ts
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
// Country types
|
||||||
|
export interface Country {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
codeAlpha3?: string;
|
||||||
|
name: string;
|
||||||
|
phoneCode?: string;
|
||||||
|
currencyCode?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State types
|
||||||
|
export interface State {
|
||||||
|
id: string;
|
||||||
|
countryId: string;
|
||||||
|
country?: Country;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
timezone?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStateDto {
|
||||||
|
countryId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
timezone?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStateDto {
|
||||||
|
name?: string;
|
||||||
|
timezone?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency types
|
||||||
|
export interface Currency {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
rounding: number;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCurrencyDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrencyDto {
|
||||||
|
name?: string;
|
||||||
|
symbol?: string;
|
||||||
|
decimals?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency Rate types
|
||||||
|
export interface CurrencyRate {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string;
|
||||||
|
fromCurrencyId: string;
|
||||||
|
fromCurrency?: Currency;
|
||||||
|
toCurrencyId: string;
|
||||||
|
toCurrency?: Currency;
|
||||||
|
rate: number;
|
||||||
|
rateDate: string;
|
||||||
|
source: 'manual' | 'banxico' | 'xe' | 'openexchange';
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCurrencyRateDto {
|
||||||
|
fromCurrencyCode: string;
|
||||||
|
toCurrencyCode: string;
|
||||||
|
rate: number;
|
||||||
|
rateDate?: string;
|
||||||
|
source?: 'manual' | 'banxico' | 'xe' | 'openexchange';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrencyConversion {
|
||||||
|
originalAmount: number;
|
||||||
|
convertedAmount: number;
|
||||||
|
rate: number;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestRates {
|
||||||
|
base: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UoM Category types
|
||||||
|
export interface UomCategory {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UoM types
|
||||||
|
export type UomType = 'reference' | 'bigger' | 'smaller';
|
||||||
|
|
||||||
|
export interface Uom {
|
||||||
|
id: string;
|
||||||
|
tenantId?: string;
|
||||||
|
categoryId: string;
|
||||||
|
category?: UomCategory;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
uomType: UomType;
|
||||||
|
factor: number;
|
||||||
|
rounding: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUomDto {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
uomType?: UomType;
|
||||||
|
ratio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUomDto {
|
||||||
|
name?: string;
|
||||||
|
ratio?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UomConversion {
|
||||||
|
originalQuantity: number;
|
||||||
|
originalUom: string;
|
||||||
|
convertedQuantity: number;
|
||||||
|
targetUom: string;
|
||||||
|
factor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionTableEntry {
|
||||||
|
uom: Uom;
|
||||||
|
toReference: number;
|
||||||
|
fromReference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionTableConversion {
|
||||||
|
fromCode: string;
|
||||||
|
toCode: string;
|
||||||
|
factor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionTableUnit {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
isReference: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionTable {
|
||||||
|
categoryName: string;
|
||||||
|
referenceUnit: string;
|
||||||
|
referenceUom: Uom;
|
||||||
|
units: ConversionTableUnit[];
|
||||||
|
conversions: ConversionTableConversion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product Category types
|
||||||
|
export interface ProductCategory {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
parentId?: string;
|
||||||
|
parent?: ProductCategory;
|
||||||
|
code?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
hierarchyPath?: string;
|
||||||
|
hierarchyLevel: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProductCategoryDto {
|
||||||
|
code?: string;
|
||||||
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductCategoryDto {
|
||||||
|
name?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment Term types
|
||||||
|
export interface PaymentTermLine {
|
||||||
|
id: string;
|
||||||
|
paymentTermId: string;
|
||||||
|
sequence: number;
|
||||||
|
lineType: 'balance' | 'percent' | 'fixed';
|
||||||
|
valuePercent?: number;
|
||||||
|
valueAmount?: number;
|
||||||
|
days: number;
|
||||||
|
dayOfMonth?: number;
|
||||||
|
endOfMonth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTerm {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
dueDays?: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
discountDays?: number;
|
||||||
|
isImmediate: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
lines: PaymentTermLine[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePaymentTermDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
dueDays?: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
discountDays?: number;
|
||||||
|
isImmediate?: boolean;
|
||||||
|
lines?: Partial<PaymentTermLine>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount Rule types
|
||||||
|
export type DiscountType = 'percentage' | 'fixed' | 'price_override';
|
||||||
|
export type DiscountAppliesTo = 'all' | 'category' | 'product' | 'customer' | 'customer_group';
|
||||||
|
export type DiscountCondition = 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
|
||||||
|
|
||||||
|
export interface DiscountRule {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discountType: DiscountType;
|
||||||
|
discountValue: number;
|
||||||
|
maxDiscountAmount?: number;
|
||||||
|
appliesTo: DiscountAppliesTo;
|
||||||
|
appliesToId?: string;
|
||||||
|
conditionType: DiscountCondition;
|
||||||
|
conditionValue?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
priority: number;
|
||||||
|
combinable: boolean;
|
||||||
|
usageLimit?: number;
|
||||||
|
usageCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user