diff --git a/src/features/catalogs/api/countries.api.ts b/src/features/catalogs/api/countries.api.ts new file mode 100644 index 0000000..828a5df --- /dev/null +++ b/src/features/catalogs/api/countries.api.ts @@ -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 => { + const response = await axios.get(`${API_BASE}/countries`); + return response.data.data; + }, + + getById: async (id: string): Promise => { + const response = await axios.get(`${API_BASE}/countries/${id}`); + return response.data.data; + }, + + // States + getStates: async (params?: { countryId?: string; countryCode?: string; active?: boolean }): Promise => { + const response = await axios.get(`${API_BASE}/states`, { params }); + return response.data.data; + }, + + getStatesByCountry: async (countryId: string): Promise => { + const response = await axios.get(`${API_BASE}/countries/${countryId}/states`); + return response.data.data; + }, + + getStatesByCountryCode: async (countryCode: string): Promise => { + const response = await axios.get(`${API_BASE}/countries/code/${countryCode}/states`); + return response.data.data; + }, + + getStateById: async (id: string): Promise => { + const response = await axios.get(`${API_BASE}/states/${id}`); + return response.data.data; + }, + + createState: async (data: CreateStateDto): Promise => { + const response = await axios.post(`${API_BASE}/states`, data); + return response.data.data; + }, + + updateState: async (id: string, data: UpdateStateDto): Promise => { + const response = await axios.put(`${API_BASE}/states/${id}`, data); + return response.data.data; + }, + + deleteState: async (id: string): Promise => { + await axios.delete(`${API_BASE}/states/${id}`); + }, +}; diff --git a/src/features/catalogs/api/currencies.api.ts b/src/features/catalogs/api/currencies.api.ts new file mode 100644 index 0000000..7a79531 --- /dev/null +++ b/src/features/catalogs/api/currencies.api.ts @@ -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 => { + const params = activeOnly ? { active: 'true' } : {}; + const response = await axios.get(`${API_BASE}/currencies`, { params }); + return response.data.data; + }, + + getById: async (id: string): Promise => { + const response = await axios.get(`${API_BASE}/currencies/${id}`); + return response.data.data; + }, + + create: async (data: CreateCurrencyDto): Promise => { + const response = await axios.post(`${API_BASE}/currencies`, data); + return response.data.data; + }, + + update: async (id: string, data: UpdateCurrencyDto): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + const response = await axios.post(`${API_BASE}/currency-rates/convert`, { + amount, + fromCurrencyCode, + toCurrencyCode, + date, + }); + return response.data.data; + }, + + deleteRate: async (id: string): Promise => { + await axios.delete(`${API_BASE}/currency-rates/${id}`); + }, +}; diff --git a/src/features/catalogs/api/index.ts b/src/features/catalogs/api/index.ts new file mode 100644 index 0000000..362629b --- /dev/null +++ b/src/features/catalogs/api/index.ts @@ -0,0 +1,3 @@ +export { countriesApi } from './countries.api'; +export { currenciesApi } from './currencies.api'; +export { uomApi } from './uom.api'; diff --git a/src/features/catalogs/api/uom.api.ts b/src/features/catalogs/api/uom.api.ts new file mode 100644 index 0000000..049d91c --- /dev/null +++ b/src/features/catalogs/api/uom.api.ts @@ -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 => { + const params = activeOnly ? { active: 'true' } : {}; + const response = await axios.get(`${API_BASE}/uom-categories`, { params }); + return response.data.data; + }, + + getCategoryById: async (id: string): Promise => { + const response = await axios.get(`${API_BASE}/uom-categories/${id}`); + return response.data.data; + }, + + // UoM + getAll: async (params?: { categoryId?: string; active?: boolean }): Promise => { + const queryParams: Record = {}; + 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 => { + const response = await axios.get(`${API_BASE}/uom/${id}`); + return response.data.data; + }, + + getByCode: async (code: string): Promise => { + 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 => { + const response = await axios.post(`${API_BASE}/uom`, data); + return response.data.data; + }, + + update: async (id: string, data: UpdateUomDto): Promise => { + const response = await axios.put(`${API_BASE}/uom/${id}`, data); + return response.data.data; + }, + + // Conversions + convert: async ( + quantity: number, + fromUomId: string, + toUomId: string + ): Promise => { + const response = await axios.post(`${API_BASE}/uom/convert`, { + quantity, + fromUomId, + toUomId, + }); + return response.data.data; + }, + + getConversionTable: async (categoryId: string): Promise => { + const response = await axios.get(`${API_BASE}/uom-categories/${categoryId}/conversions`); + return response.data.data; + }, +}; diff --git a/src/features/catalogs/components/AddressInput.tsx b/src/features/catalogs/components/AddressInput.tsx new file mode 100644 index 0000000..d01829b --- /dev/null +++ b/src/features/catalogs/components/AddressInput.tsx @@ -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>; + showLabels?: boolean; + compact?: boolean; +} + +export const AddressInput: React.FC = ({ + value = {}, + onChange, + disabled = false, + required = false, + className = '', + errors = {}, + showLabels = true, + compact = false, +}) => { + const [selectedCountry, setSelectedCountry] = useState(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 ( + + ); + }; + + const renderError = (field: keyof AddressData) => { + if (!errors[field]) return null; + return

{errors[field]}

; + }; + + if (compact) { + return ( +
+
+
+ {renderLabel('Calle', required)} + handleFieldChange('street', e.target.value)} + placeholder="Calle" + disabled={disabled} + className={getInputClass('street')} + /> + {renderError('street')} +
+
+
+ {renderLabel('No. Ext')} + handleFieldChange('exteriorNumber', e.target.value)} + placeholder="Ext" + disabled={disabled} + className={getInputClass('exteriorNumber')} + /> +
+
+ {renderLabel('No. Int')} + handleFieldChange('interiorNumber', e.target.value)} + placeholder="Int" + disabled={disabled} + className={getInputClass('interiorNumber')} + /> +
+
+
+ +
+
+ {renderLabel('Colonia')} + handleFieldChange('neighborhood', e.target.value)} + placeholder="Colonia" + disabled={disabled} + className={getInputClass('neighborhood')} + /> +
+
+ {renderLabel('Ciudad', required)} + handleFieldChange('city', e.target.value)} + placeholder="Ciudad" + disabled={disabled} + className={getInputClass('city')} + /> + {renderError('city')} +
+
+ {renderLabel('C.P.')} + handleFieldChange('postalCode', e.target.value)} + placeholder="C.P." + disabled={disabled} + className={getInputClass('postalCode')} + /> +
+
+ +
+ + +
+
+ ); + } + + return ( +
+
+ {renderLabel('Calle', required)} + handleFieldChange('street', e.target.value)} + placeholder="Nombre de la calle" + disabled={disabled} + className={getInputClass('street')} + /> + {renderError('street')} +
+ +
+
+ {renderLabel('Número Exterior')} + handleFieldChange('exteriorNumber', e.target.value)} + placeholder="No. Ext" + disabled={disabled} + className={getInputClass('exteriorNumber')} + /> + {renderError('exteriorNumber')} +
+
+ {renderLabel('Número Interior')} + handleFieldChange('interiorNumber', e.target.value)} + placeholder="No. Int (opcional)" + disabled={disabled} + className={getInputClass('interiorNumber')} + /> + {renderError('interiorNumber')} +
+
+ {renderLabel('Código Postal')} + handleFieldChange('postalCode', e.target.value)} + placeholder="C.P." + disabled={disabled} + className={getInputClass('postalCode')} + /> + {renderError('postalCode')} +
+
+ +
+
+ {renderLabel('Colonia')} + handleFieldChange('neighborhood', e.target.value)} + placeholder="Colonia o fraccionamiento" + disabled={disabled} + className={getInputClass('neighborhood')} + /> + {renderError('neighborhood')} +
+
+ {renderLabel('Ciudad', required)} + handleFieldChange('city', e.target.value)} + placeholder="Ciudad o municipio" + disabled={disabled} + className={getInputClass('city')} + /> + {renderError('city')} +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/features/catalogs/components/ConversionTableDisplay.tsx b/src/features/catalogs/components/ConversionTableDisplay.tsx new file mode 100644 index 0000000..c3b0d33 --- /dev/null +++ b/src/features/catalogs/components/ConversionTableDisplay.tsx @@ -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 = ({ + categoryId, + className = '', + compact = false, +}) => { + const { table, isLoading, error } = useConversionTable(categoryId); + + if (!categoryId) { + return ( +
+

Seleccione una categoría para ver las conversiones

+
+ ); + } + + if (isLoading) { + return ( +
+
+
+ Cargando conversiones... +
+
+ ); + } + + if (error) { + return ( +
+

{error.message}

+
+ ); + } + + if (!table || table.units.length === 0) { + return ( +
+

No hay unidades en esta categoría

+
+ ); + } + + const { categoryName, referenceUnit, units, conversions } = table; + + if (compact) { + return ( +
+

{categoryName}

+
+ {conversions.slice(0, 5).map((conv, idx) => ( +
+ + 1 {conv.fromCode} → + + + {conv.factor.toFixed(4)} {conv.toCode} + +
+ ))} + {conversions.length > 5 && ( +

+ +{conversions.length - 5} más... +

+ )} +
+
+ ); + } + + return ( +
+
+

{categoryName}

+

+ Unidad de referencia: {referenceUnit} +

+
+ +
+
+ {units.map((unit) => ( + + {unit.code} + {unit.isReference && ( + * + )} + + ))} +
+ +
+ + + + + + + + + + {conversions.map((conv, idx) => ( + + + + + + ))} + +
+ De + + A + + Factor +
+ 1 {conv.fromCode} + + {conv.toCode} + + {conv.factor.toFixed(6)} +
+
+
+
+ ); +}; diff --git a/src/features/catalogs/components/CountrySelect.tsx b/src/features/catalogs/components/CountrySelect.tsx new file mode 100644 index 0000000..9ef8f02 --- /dev/null +++ b/src/features/catalogs/components/CountrySelect.tsx @@ -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 = ({ + value, + onChange, + placeholder = 'Seleccionar país', + disabled = false, + required = false, + className = '', + error, + label, +}) => { + const { countries, isLoading, error: loadError } = useCountries(); + + const handleChange = (e: React.ChangeEvent) => { + 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 ( +
+ {label && ( + + )} + + {(error || loadError) && ( +

+ {error || loadError?.message} +

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/CurrencyInput.tsx b/src/features/catalogs/components/CurrencyInput.tsx new file mode 100644 index 0000000..20ec211 --- /dev/null +++ b/src/features/catalogs/components/CurrencyInput.tsx @@ -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 = ({ + 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(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) => { + 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 ( +
+ {label && ( + + )} +
+ {selectedCurrency?.symbol && ( + + {selectedCurrency.symbol} + + )} + + {selectedCurrency && ( + + {selectedCurrency.code} + + )} +
+ {showConversion && conversion && ( +

+ {isConverting ? ( + 'Calculando...' + ) : ( + <> + ≈ {formatAmount(conversion.convertedAmount, baseCurrency)} + + (TC: {conversion.rate.toFixed(4)}) + + + )} +

+ )} + {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/CurrencyRatesDisplay.tsx b/src/features/catalogs/components/CurrencyRatesDisplay.tsx new file mode 100644 index 0000000..caad63e --- /dev/null +++ b/src/features/catalogs/components/CurrencyRatesDisplay.tsx @@ -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 = ({ + baseCurrency = 'MXN', + className = '', + compact = false, +}) => { + const { data, isLoading, error, refresh } = useLatestRates(baseCurrency); + + if (isLoading) { + return ( +
+
+
+ Cargando tipos de cambio... +
+
+ ); + } + + if (error) { + return ( +
+

{error.message}

+ +
+ ); + } + + if (!data || Object.keys(data.rates).length === 0) { + return ( +
+

No hay tipos de cambio disponibles

+
+ ); + } + + 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 ( +
+
+

+ Tipos de Cambio ({baseCurrency}) +

+

{formatDate(data.date)}

+
+
+ {rates.slice(0, 4).map(([currency, rate]) => ( +
+ {currency} + {rate.toFixed(4)} +
+ ))} +
+ {rates.length > 4 && ( +

+{rates.length - 4} más

+ )} +
+ ); + } + + return ( +
+
+
+

Tipos de Cambio

+

+ Base: {baseCurrency} | + Fecha: {formatDate(data.date)} +

+
+ +
+ +
+
+ {rates.map(([currency, rate]) => ( +
+
+ {currency} +
+
+ + {rate.toFixed(4)} + +

+ 1 {currency} = {(1 / rate).toFixed(4)} {baseCurrency} +

+
+
+ ))} +
+
+
+ ); +}; diff --git a/src/features/catalogs/components/CurrencySelect.tsx b/src/features/catalogs/components/CurrencySelect.tsx new file mode 100644 index 0000000..19084b5 --- /dev/null +++ b/src/features/catalogs/components/CurrencySelect.tsx @@ -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 = ({ + 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) => { + 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 ( +
+ {label && ( + + )} + + {(error || loadError) && ( +

+ {error || loadError?.message} +

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/StateSelect.tsx b/src/features/catalogs/components/StateSelect.tsx new file mode 100644 index 0000000..f5b90e3 --- /dev/null +++ b/src/features/catalogs/components/StateSelect.tsx @@ -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 = ({ + 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) => { + 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 ( +
+ {label && ( + + )} + + {(error || loadError) && ( +

+ {error || loadError?.message} +

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/UomCategorySelect.tsx b/src/features/catalogs/components/UomCategorySelect.tsx new file mode 100644 index 0000000..f8cd08c --- /dev/null +++ b/src/features/catalogs/components/UomCategorySelect.tsx @@ -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 = ({ + value, + onChange, + placeholder = 'Seleccionar categoría', + disabled = false, + required = false, + className = '', + error, + label, +}) => { + const { categories, isLoading, error: loadError } = useUomCategories(); + + const handleChange = (e: React.ChangeEvent) => { + 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 ( +
+ {label && ( + + )} + + {(error || loadError) && ( +

+ {error || loadError?.message} +

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/UomQuantityInput.tsx b/src/features/catalogs/components/UomQuantityInput.tsx new file mode 100644 index 0000000..5c69bbc --- /dev/null +++ b/src/features/catalogs/components/UomQuantityInput.tsx @@ -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 = ({ + 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(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) => { + 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 ( +
+ {label && ( + + )} +
+ + {selectedUom && ( + + {selectedUom.code} + + )} +
+ {showConversion && conversion && ( +

+ {isConverting ? ( + 'Calculando...' + ) : ( + <> + = {formatQuantity(conversion.convertedQuantity, targetUom)} + + (factor: {conversion.factor.toFixed(6)}) + + + )} +

+ )} + {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/UomSelect.tsx b/src/features/catalogs/components/UomSelect.tsx new file mode 100644 index 0000000..e9b8117 --- /dev/null +++ b/src/features/catalogs/components/UomSelect.tsx @@ -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 = ({ + 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) => { + 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 ( +
+ {label && ( + + )} + + {(error || loadError) && ( +

+ {error || loadError?.message} +

+ )} +
+ ); +}; diff --git a/src/features/catalogs/components/index.ts b/src/features/catalogs/components/index.ts new file mode 100644 index 0000000..898d2d0 --- /dev/null +++ b/src/features/catalogs/components/index.ts @@ -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'; diff --git a/src/features/catalogs/hooks/index.ts b/src/features/catalogs/hooks/index.ts new file mode 100644 index 0000000..3afcdf6 --- /dev/null +++ b/src/features/catalogs/hooks/index.ts @@ -0,0 +1,3 @@ +export { useCountries, useCountry, useStates } from './useCountries'; +export { useCurrencies, useCurrencyRates, useLatestRates, useCurrencyConversion } from './useCurrencies'; +export { useUomCategories, useUom, useUomConversion, useConversionTable } from './useUom'; diff --git a/src/features/catalogs/hooks/useCountries.ts b/src/features/catalogs/hooks/useCountries.ts new file mode 100644 index 0000000..fbc4eae --- /dev/null +++ b/src/features/catalogs/hooks/useCountries.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const newState = await countriesApi.createState(data); + setStates(prev => [...prev, newState]); + return newState; + }, []); + + const updateState = useCallback(async (id: string, data: UpdateStateDto): Promise => { + 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 => { + await countriesApi.deleteState(id); + setStates(prev => prev.filter(s => s.id !== id)); + }, []); + + return { + states, + isLoading, + error, + refresh: fetchStates, + createState, + updateState, + deleteState, + }; +} diff --git a/src/features/catalogs/hooks/useCurrencies.ts b/src/features/catalogs/hooks/useCurrencies.ts new file mode 100644 index 0000000..7f4ceb1 --- /dev/null +++ b/src/features/catalogs/hooks/useCurrencies.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const newCurrency = await currenciesApi.create(data); + setCurrencies(prev => [...prev, newCurrency]); + return newCurrency; + }, []); + + const updateCurrency = useCallback(async (id: string, data: UpdateCurrencyDto): Promise => { + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const newRate = await currenciesApi.createRate(data); + setRates(prev => [newRate, ...prev]); + return newRate; + }, []); + + const deleteRate = useCallback(async (id: string): Promise => { + 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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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(null); + + const convert = useCallback(async ( + amount: number, + from: string, + to: string, + date?: string + ): Promise => { + 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, + }; +} diff --git a/src/features/catalogs/hooks/useUom.ts b/src/features/catalogs/hooks/useUom.ts new file mode 100644 index 0000000..d1d59f1 --- /dev/null +++ b/src/features/catalogs/hooks/useUom.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const newUom = await uomApi.create(data); + setUoms(prev => [...prev, newUom]); + return newUom; + }, []); + + const updateUom = useCallback(async (id: string, data: UpdateUomDto): Promise => { + 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(null); + + const convert = useCallback(async ( + quantity: number, + fromUomId: string, + toUomId: string + ): Promise => { + 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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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, + }; +} diff --git a/src/features/catalogs/index.ts b/src/features/catalogs/index.ts new file mode 100644 index 0000000..5b8566c --- /dev/null +++ b/src/features/catalogs/index.ts @@ -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'; diff --git a/src/features/catalogs/types/index.ts b/src/features/catalogs/types/index.ts new file mode 100644 index 0000000..356f27e --- /dev/null +++ b/src/features/catalogs/types/index.ts @@ -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; + 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[]; +} + +// 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; +}