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:
rckrdmrd 2026-01-18 09:09:41 -06:00
parent 28b27565f8
commit 94ba9f6bcd
21 changed files with 2240 additions and 0 deletions

View 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}`);
},
};

View 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}`);
},
};

View File

@ -0,0 +1,3 @@
export { countriesApi } from './countries.api';
export { currenciesApi } from './currencies.api';
export { uomApi } from './uom.api';

View 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;
},
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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';

View 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';

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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';

View 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;
}