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