feat(catalogs): Add fiscal catalog components (MGN-005)
- Add types for fiscal entities (TaxCategory, FiscalRegime, CfdiUse, etc.) - Add API clients for fiscal endpoints - Add hooks for fiscal data fetching - Add components: - FiscalRegimeSelect - SAT fiscal regime selector - CfdiUseSelect - CFDI use code selector - FiscalPaymentMethodSelect - SAT payment method selector - FiscalPaymentTypeSelect - PUE/PPD selector - TaxCategorySelect - Tax category selector - WithholdingTypeSelect - Withholding type selector - FiscalDataInput - Compound component for fiscal data collection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
94ba9f6bcd
commit
c9e1c3fb06
255
src/features/catalogs/api/fiscal.api.ts
Normal file
255
src/features/catalogs/api/fiscal.api.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
TaxCategory,
|
||||
FiscalRegime,
|
||||
CfdiUse,
|
||||
FiscalPaymentMethod,
|
||||
FiscalPaymentType,
|
||||
WithholdingType,
|
||||
TaxNature,
|
||||
PersonType,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api/v1/fiscal';
|
||||
|
||||
// ========== TAX CATEGORIES API ==========
|
||||
|
||||
export interface TaxCategoryFilter {
|
||||
taxNature?: TaxNature;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const taxCategoriesApi = {
|
||||
getAll: async (filter?: TaxCategoryFilter): Promise<TaxCategory[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.taxNature) params.append('tax_nature', filter.taxNature);
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/tax-categories`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<TaxCategory> => {
|
||||
const response = await axios.get(`${API_BASE}/tax-categories/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<TaxCategory | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/tax-categories/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getBySatCode: async (satCode: string): Promise<TaxCategory | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/tax-categories/by-sat-code/${satCode}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ========== FISCAL REGIMES API ==========
|
||||
|
||||
export interface FiscalRegimeFilter {
|
||||
appliesTo?: PersonType;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const fiscalRegimesApi = {
|
||||
getAll: async (filter?: FiscalRegimeFilter): Promise<FiscalRegime[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.appliesTo) params.append('applies_to', filter.appliesTo);
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/fiscal-regimes`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<FiscalRegime> => {
|
||||
const response = await axios.get(`${API_BASE}/fiscal-regimes/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<FiscalRegime | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/fiscal-regimes/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getForPersonType: async (personType: PersonType): Promise<FiscalRegime[]> => {
|
||||
const response = await axios.get(`${API_BASE}/fiscal-regimes/person-type/${personType}`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ========== CFDI USES API ==========
|
||||
|
||||
export interface CfdiUseFilter {
|
||||
appliesTo?: PersonType;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const cfdiUsesApi = {
|
||||
getAll: async (filter?: CfdiUseFilter): Promise<CfdiUse[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.appliesTo) params.append('applies_to', filter.appliesTo);
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/cfdi-uses`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<CfdiUse> => {
|
||||
const response = await axios.get(`${API_BASE}/cfdi-uses/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<CfdiUse | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/cfdi-uses/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getForPersonType: async (personType: PersonType): Promise<CfdiUse[]> => {
|
||||
const response = await axios.get(`${API_BASE}/cfdi-uses/person-type/${personType}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getForRegime: async (regimeCode: string): Promise<CfdiUse[]> => {
|
||||
const response = await axios.get(`${API_BASE}/cfdi-uses/regime/${regimeCode}`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ========== PAYMENT METHODS API (SAT c_FormaPago) ==========
|
||||
|
||||
export interface PaymentMethodFilter {
|
||||
requiresBankInfo?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const fiscalPaymentMethodsApi = {
|
||||
getAll: async (filter?: PaymentMethodFilter): Promise<FiscalPaymentMethod[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.requiresBankInfo !== undefined) {
|
||||
params.append('requires_bank_info', String(filter.requiresBankInfo));
|
||||
}
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/payment-methods`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<FiscalPaymentMethod> => {
|
||||
const response = await axios.get(`${API_BASE}/payment-methods/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<FiscalPaymentMethod | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/payment-methods/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ========== PAYMENT TYPES API (SAT c_MetodoPago) ==========
|
||||
|
||||
export interface PaymentTypeFilter {
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const fiscalPaymentTypesApi = {
|
||||
getAll: async (filter?: PaymentTypeFilter): Promise<FiscalPaymentType[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/payment-types`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<FiscalPaymentType> => {
|
||||
const response = await axios.get(`${API_BASE}/payment-types/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<FiscalPaymentType | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/payment-types/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ========== WITHHOLDING TYPES API ==========
|
||||
|
||||
export interface WithholdingTypeFilter {
|
||||
taxCategoryId?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const withholdingTypesApi = {
|
||||
getAll: async (filter?: WithholdingTypeFilter): Promise<WithholdingType[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.taxCategoryId) params.append('tax_category_id', filter.taxCategoryId);
|
||||
if (filter?.active !== undefined) params.append('active', String(filter.active));
|
||||
|
||||
const response = await axios.get(`${API_BASE}/withholding-types`, { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<WithholdingType> => {
|
||||
const response = await axios.get(`${API_BASE}/withholding-types/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getByCode: async (code: string): Promise<WithholdingType | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/withholding-types/by-code/${code}`);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getByCategory: async (categoryId: string): Promise<WithholdingType[]> => {
|
||||
const response = await axios.get(`${API_BASE}/withholding-types/by-category/${categoryId}`);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
@ -1,3 +1,19 @@
|
||||
export { countriesApi } from './countries.api';
|
||||
export { currenciesApi } from './currencies.api';
|
||||
export { uomApi } from './uom.api';
|
||||
export {
|
||||
taxCategoriesApi,
|
||||
fiscalRegimesApi,
|
||||
cfdiUsesApi,
|
||||
fiscalPaymentMethodsApi,
|
||||
fiscalPaymentTypesApi,
|
||||
withholdingTypesApi,
|
||||
} from './fiscal.api';
|
||||
export type {
|
||||
TaxCategoryFilter,
|
||||
FiscalRegimeFilter,
|
||||
CfdiUseFilter,
|
||||
PaymentMethodFilter,
|
||||
PaymentTypeFilter,
|
||||
WithholdingTypeFilter,
|
||||
} from './fiscal.api';
|
||||
|
||||
68
src/features/catalogs/components/CfdiUseSelect.tsx
Normal file
68
src/features/catalogs/components/CfdiUseSelect.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useCfdiUses, useCfdiUsesForRegime } from '../hooks/useFiscalCatalogs';
|
||||
import type { PersonType } from '../types';
|
||||
|
||||
interface CfdiUseSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
personType?: PersonType;
|
||||
regimeCode?: string;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function CfdiUseSelect({
|
||||
value,
|
||||
onChange,
|
||||
personType,
|
||||
regimeCode,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar uso de CFDI',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
}: CfdiUseSelectProps) {
|
||||
// Use regime specific hook if regimeCode is provided, otherwise use general hook
|
||||
const allUses = useCfdiUses({ appliesTo: personType, active: activeOnly });
|
||||
const regimeUses = useCfdiUsesForRegime(regimeCode);
|
||||
|
||||
const { uses, loading, error: loadError } = regimeCode ? regimeUses : allUses;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{uses.map((use) => (
|
||||
<option key={use.id} value={use.id}>
|
||||
{use.code} - {use.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CfdiUseSelect;
|
||||
130
src/features/catalogs/components/FiscalDataInput.tsx
Normal file
130
src/features/catalogs/components/FiscalDataInput.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { FiscalRegimeSelect } from './FiscalRegimeSelect';
|
||||
import { CfdiUseSelect } from './CfdiUseSelect';
|
||||
import { FiscalPaymentMethodSelect } from './FiscalPaymentMethodSelect';
|
||||
import { FiscalPaymentTypeSelect } from './FiscalPaymentTypeSelect';
|
||||
import type { PersonType } from '../types';
|
||||
|
||||
export interface FiscalData {
|
||||
taxId?: string;
|
||||
fiscalRegimeId?: string;
|
||||
cfdiUseId?: string;
|
||||
paymentMethodId?: string;
|
||||
paymentTypeId?: string;
|
||||
}
|
||||
|
||||
interface FiscalDataInputProps {
|
||||
value: FiscalData;
|
||||
onChange: (value: FiscalData) => void;
|
||||
personType?: PersonType;
|
||||
showTaxId?: boolean;
|
||||
showPaymentFields?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
errors?: {
|
||||
taxId?: string;
|
||||
fiscalRegimeId?: string;
|
||||
cfdiUseId?: string;
|
||||
paymentMethodId?: string;
|
||||
paymentTypeId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function FiscalDataInput({
|
||||
value,
|
||||
onChange,
|
||||
personType = 'both',
|
||||
showTaxId = true,
|
||||
showPaymentFields = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
errors = {},
|
||||
}: FiscalDataInputProps) {
|
||||
const handleChange = (field: keyof FiscalData, fieldValue: string) => {
|
||||
onChange({ ...value, [field]: fieldValue });
|
||||
};
|
||||
|
||||
const handleRegimeChange = (regimeId: string) => {
|
||||
handleChange('fiscalRegimeId', regimeId);
|
||||
// Reset CFDI use when regime changes
|
||||
handleChange('cfdiUseId', '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<h3 className="text-lg font-medium text-gray-900">Datos Fiscales</h3>
|
||||
|
||||
{showTaxId && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
RFC <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.taxId || ''}
|
||||
onChange={(e) => handleChange('taxId', e.target.value.toUpperCase())}
|
||||
placeholder="XAXX010101000"
|
||||
disabled={disabled}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed uppercase ${
|
||||
errors.taxId ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
maxLength={13}
|
||||
/>
|
||||
{errors.taxId && (
|
||||
<span className="text-sm text-red-500">{errors.taxId}</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
{personType === 'natural'
|
||||
? '13 caracteres para persona física'
|
||||
: personType === 'legal'
|
||||
? '12 caracteres para persona moral'
|
||||
: '12-13 caracteres'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FiscalRegimeSelect
|
||||
value={value.fiscalRegimeId || ''}
|
||||
onChange={handleRegimeChange}
|
||||
personType={personType}
|
||||
disabled={disabled}
|
||||
label="Régimen Fiscal"
|
||||
required
|
||||
error={errors.fiscalRegimeId}
|
||||
/>
|
||||
|
||||
<CfdiUseSelect
|
||||
value={value.cfdiUseId || ''}
|
||||
onChange={(v) => handleChange('cfdiUseId', v)}
|
||||
personType={personType}
|
||||
disabled={disabled}
|
||||
label="Uso del CFDI"
|
||||
required
|
||||
error={errors.cfdiUseId}
|
||||
/>
|
||||
|
||||
{showPaymentFields && (
|
||||
<>
|
||||
<FiscalPaymentMethodSelect
|
||||
value={value.paymentMethodId || ''}
|
||||
onChange={(v) => handleChange('paymentMethodId', v)}
|
||||
disabled={disabled}
|
||||
label="Forma de Pago (SAT)"
|
||||
required
|
||||
error={errors.paymentMethodId}
|
||||
/>
|
||||
|
||||
<FiscalPaymentTypeSelect
|
||||
value={value.paymentTypeId || ''}
|
||||
onChange={(v) => handleChange('paymentTypeId', v)}
|
||||
disabled={disabled}
|
||||
label="Método de Pago (SAT)"
|
||||
required
|
||||
error={errors.paymentTypeId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiscalDataInput;
|
||||
@ -0,0 +1,59 @@
|
||||
import { useFiscalPaymentMethods } from '../hooks/useFiscalCatalogs';
|
||||
|
||||
interface FiscalPaymentMethodSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FiscalPaymentMethodSelect({
|
||||
value,
|
||||
onChange,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar forma de pago',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
}: FiscalPaymentMethodSelectProps) {
|
||||
const { methods, loading, error: loadError } = useFiscalPaymentMethods({ active: activeOnly });
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{methods.map((method) => (
|
||||
<option key={method.id} value={method.id}>
|
||||
{method.code} - {method.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiscalPaymentMethodSelect;
|
||||
62
src/features/catalogs/components/FiscalPaymentTypeSelect.tsx
Normal file
62
src/features/catalogs/components/FiscalPaymentTypeSelect.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useFiscalPaymentTypes } from '../hooks/useFiscalCatalogs';
|
||||
|
||||
interface FiscalPaymentTypeSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FiscalPaymentTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar método de pago',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
}: FiscalPaymentTypeSelectProps) {
|
||||
const { types, loading, error: loadError } = useFiscalPaymentTypes({ active: activeOnly });
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{types.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.code} - {type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
PUE: Pago en una exhibición | PPD: Pago en parcialidades o diferido
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiscalPaymentTypeSelect;
|
||||
66
src/features/catalogs/components/FiscalRegimeSelect.tsx
Normal file
66
src/features/catalogs/components/FiscalRegimeSelect.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useFiscalRegimes, useFiscalRegimesByPersonType } from '../hooks/useFiscalCatalogs';
|
||||
import type { PersonType } from '../types';
|
||||
|
||||
interface FiscalRegimeSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
personType?: PersonType;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function FiscalRegimeSelect({
|
||||
value,
|
||||
onChange,
|
||||
personType,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar régimen fiscal',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
}: FiscalRegimeSelectProps) {
|
||||
// Use person type specific hook if personType is provided
|
||||
const allRegimes = useFiscalRegimes({ active: activeOnly });
|
||||
const filteredRegimes = useFiscalRegimesByPersonType(personType);
|
||||
|
||||
const { regimes, loading, error: loadError } = personType ? filteredRegimes : allRegimes;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{regimes.map((regime) => (
|
||||
<option key={regime.id} value={regime.id}>
|
||||
{regime.code} - {regime.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FiscalRegimeSelect;
|
||||
65
src/features/catalogs/components/TaxCategorySelect.tsx
Normal file
65
src/features/catalogs/components/TaxCategorySelect.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useTaxCategories } from '../hooks/useFiscalCatalogs';
|
||||
import type { TaxNature } from '../types';
|
||||
|
||||
interface TaxCategorySelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
taxNature?: TaxNature;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function TaxCategorySelect({
|
||||
value,
|
||||
onChange,
|
||||
taxNature,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar categoría de impuesto',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
}: TaxCategorySelectProps) {
|
||||
const { categories, loading, error: loadError } = useTaxCategories({
|
||||
taxNature,
|
||||
active: activeOnly,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.code} - {cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaxCategorySelect;
|
||||
67
src/features/catalogs/components/WithholdingTypeSelect.tsx
Normal file
67
src/features/catalogs/components/WithholdingTypeSelect.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useWithholdingTypes, useWithholdingTypesByCategory } from '../hooks/useFiscalCatalogs';
|
||||
|
||||
interface WithholdingTypeSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
taxCategoryId?: string;
|
||||
activeOnly?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
showRate?: boolean;
|
||||
}
|
||||
|
||||
export function WithholdingTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
taxCategoryId,
|
||||
activeOnly = true,
|
||||
placeholder = 'Seleccionar tipo de retención',
|
||||
disabled = false,
|
||||
className = '',
|
||||
error,
|
||||
required = false,
|
||||
label,
|
||||
showRate = true,
|
||||
}: WithholdingTypeSelectProps) {
|
||||
// Use category specific hook if taxCategoryId is provided
|
||||
const allTypes = useWithholdingTypes({ active: activeOnly });
|
||||
const categoryTypes = useWithholdingTypesByCategory(taxCategoryId);
|
||||
|
||||
const { types, loading, error: loadError } = taxCategoryId ? categoryTypes : allTypes;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className={`block w-full px-3 py-2 bg-white border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${
|
||||
error || loadError ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
required={required}
|
||||
>
|
||||
<option value="">{loading ? 'Cargando...' : placeholder}</option>
|
||||
{types.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.code} - {type.name} {showRate && `(${type.defaultRate}%)`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(error || loadError) && (
|
||||
<span className="text-sm text-red-500">{error || loadError}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WithholdingTypeSelect;
|
||||
@ -14,3 +14,13 @@ export type { AddressData } from './AddressInput';
|
||||
// Display components
|
||||
export { ConversionTableDisplay } from './ConversionTableDisplay';
|
||||
export { CurrencyRatesDisplay } from './CurrencyRatesDisplay';
|
||||
|
||||
// Fiscal components
|
||||
export { FiscalRegimeSelect } from './FiscalRegimeSelect';
|
||||
export { CfdiUseSelect } from './CfdiUseSelect';
|
||||
export { FiscalPaymentMethodSelect } from './FiscalPaymentMethodSelect';
|
||||
export { FiscalPaymentTypeSelect } from './FiscalPaymentTypeSelect';
|
||||
export { TaxCategorySelect } from './TaxCategorySelect';
|
||||
export { WithholdingTypeSelect } from './WithholdingTypeSelect';
|
||||
export { FiscalDataInput } from './FiscalDataInput';
|
||||
export type { FiscalData } from './FiscalDataInput';
|
||||
|
||||
@ -1,3 +1,14 @@
|
||||
export { useCountries, useCountry, useStates } from './useCountries';
|
||||
export { useCurrencies, useCurrencyRates, useLatestRates, useCurrencyConversion } from './useCurrencies';
|
||||
export { useUomCategories, useUom, useUomConversion, useConversionTable } from './useUom';
|
||||
export {
|
||||
useTaxCategories,
|
||||
useFiscalRegimes,
|
||||
useFiscalRegimesByPersonType,
|
||||
useCfdiUses,
|
||||
useCfdiUsesForRegime,
|
||||
useFiscalPaymentMethods,
|
||||
useFiscalPaymentTypes,
|
||||
useWithholdingTypes,
|
||||
useWithholdingTypesByCategory,
|
||||
} from './useFiscalCatalogs';
|
||||
|
||||
276
src/features/catalogs/hooks/useFiscalCatalogs.ts
Normal file
276
src/features/catalogs/hooks/useFiscalCatalogs.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
taxCategoriesApi,
|
||||
fiscalRegimesApi,
|
||||
cfdiUsesApi,
|
||||
fiscalPaymentMethodsApi,
|
||||
fiscalPaymentTypesApi,
|
||||
withholdingTypesApi,
|
||||
TaxCategoryFilter,
|
||||
FiscalRegimeFilter,
|
||||
CfdiUseFilter,
|
||||
PaymentMethodFilter,
|
||||
PaymentTypeFilter,
|
||||
WithholdingTypeFilter,
|
||||
} from '../api/fiscal.api';
|
||||
import type {
|
||||
TaxCategory,
|
||||
FiscalRegime,
|
||||
CfdiUse,
|
||||
FiscalPaymentMethod,
|
||||
FiscalPaymentType,
|
||||
WithholdingType,
|
||||
PersonType,
|
||||
} from '../types';
|
||||
|
||||
// ========== TAX CATEGORIES HOOK ==========
|
||||
|
||||
export function useTaxCategories(filter?: TaxCategoryFilter) {
|
||||
const [categories, setCategories] = useState<TaxCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await taxCategoriesApi.getAll(filter);
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar categorías de impuestos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.taxNature, filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
return { categories, loading, error, refetch: fetchCategories };
|
||||
}
|
||||
|
||||
// ========== FISCAL REGIMES HOOK ==========
|
||||
|
||||
export function useFiscalRegimes(filter?: FiscalRegimeFilter) {
|
||||
const [regimes, setRegimes] = useState<FiscalRegime[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRegimes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fiscalRegimesApi.getAll(filter);
|
||||
setRegimes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar regímenes fiscales');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.appliesTo, filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegimes();
|
||||
}, [fetchRegimes]);
|
||||
|
||||
return { regimes, loading, error, refetch: fetchRegimes };
|
||||
}
|
||||
|
||||
export function useFiscalRegimesByPersonType(personType: PersonType | undefined) {
|
||||
const [regimes, setRegimes] = useState<FiscalRegime[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRegimes = useCallback(async () => {
|
||||
if (!personType) {
|
||||
setRegimes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fiscalRegimesApi.getForPersonType(personType);
|
||||
setRegimes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar regímenes fiscales');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [personType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegimes();
|
||||
}, [fetchRegimes]);
|
||||
|
||||
return { regimes, loading, error, refetch: fetchRegimes };
|
||||
}
|
||||
|
||||
// ========== CFDI USES HOOK ==========
|
||||
|
||||
export function useCfdiUses(filter?: CfdiUseFilter) {
|
||||
const [uses, setUses] = useState<CfdiUse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchUses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await cfdiUsesApi.getAll(filter);
|
||||
setUses(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar usos de CFDI');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.appliesTo, filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUses();
|
||||
}, [fetchUses]);
|
||||
|
||||
return { uses, loading, error, refetch: fetchUses };
|
||||
}
|
||||
|
||||
export function useCfdiUsesForRegime(regimeCode: string | undefined) {
|
||||
const [uses, setUses] = useState<CfdiUse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchUses = useCallback(async () => {
|
||||
if (!regimeCode) {
|
||||
setUses([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await cfdiUsesApi.getForRegime(regimeCode);
|
||||
setUses(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar usos de CFDI');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [regimeCode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUses();
|
||||
}, [fetchUses]);
|
||||
|
||||
return { uses, loading, error, refetch: fetchUses };
|
||||
}
|
||||
|
||||
// ========== PAYMENT METHODS HOOK ==========
|
||||
|
||||
export function useFiscalPaymentMethods(filter?: PaymentMethodFilter) {
|
||||
const [methods, setMethods] = useState<FiscalPaymentMethod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchMethods = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fiscalPaymentMethodsApi.getAll(filter);
|
||||
setMethods(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar formas de pago');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.requiresBankInfo, filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMethods();
|
||||
}, [fetchMethods]);
|
||||
|
||||
return { methods, loading, error, refetch: fetchMethods };
|
||||
}
|
||||
|
||||
// ========== PAYMENT TYPES HOOK ==========
|
||||
|
||||
export function useFiscalPaymentTypes(filter?: PaymentTypeFilter) {
|
||||
const [types, setTypes] = useState<FiscalPaymentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTypes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fiscalPaymentTypesApi.getAll(filter);
|
||||
setTypes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar métodos de pago');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
return { types, loading, error, refetch: fetchTypes };
|
||||
}
|
||||
|
||||
// ========== WITHHOLDING TYPES HOOK ==========
|
||||
|
||||
export function useWithholdingTypes(filter?: WithholdingTypeFilter) {
|
||||
const [types, setTypes] = useState<WithholdingType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTypes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await withholdingTypesApi.getAll(filter);
|
||||
setTypes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar tipos de retención');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter?.taxCategoryId, filter?.active]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
return { types, loading, error, refetch: fetchTypes };
|
||||
}
|
||||
|
||||
export function useWithholdingTypesByCategory(categoryId: string | undefined) {
|
||||
const [types, setTypes] = useState<WithholdingType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTypes = useCallback(async () => {
|
||||
if (!categoryId) {
|
||||
setTypes([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await withholdingTypesApi.getByCategory(categoryId);
|
||||
setTypes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar tipos de retención');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
return { types, loading, error, refetch: fetchTypes };
|
||||
}
|
||||
@ -273,3 +273,86 @@ export interface DiscountRule {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ========== FISCAL TYPES ==========
|
||||
|
||||
// Tax Nature
|
||||
export type TaxNature = 'tax' | 'withholding' | 'both';
|
||||
|
||||
// Person Type
|
||||
export type PersonType = 'natural' | 'legal' | 'both';
|
||||
|
||||
// Tax Category (IVA, ISR, IEPS, etc.)
|
||||
export interface TaxCategory {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
taxNature: TaxNature;
|
||||
satCode?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Fiscal Regime (SAT c_RegimenFiscal)
|
||||
export interface FiscalRegime {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
appliesTo: PersonType;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// CFDI Use (SAT c_UsoCFDI)
|
||||
export interface CfdiUse {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
appliesTo: PersonType;
|
||||
allowedRegimes?: string[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Payment Method - SAT Forms of Payment (c_FormaPago)
|
||||
export interface FiscalPaymentMethod {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
requiresBankInfo: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Payment Type - SAT Payment Methods (c_MetodoPago: PUE, PPD)
|
||||
export interface FiscalPaymentType {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Withholding Type
|
||||
export interface WithholdingType {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultRate: number;
|
||||
taxCategoryId?: string;
|
||||
taxCategory?: TaxCategory;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user