Feat ERP 3
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Company } from '../../types/Company';
|
||||
import { companyService } from '../../services/companyService';
|
||||
import { X, Save, Building2, FileText, MapPin, Link } from 'lucide-react';
|
||||
import { X, Save, Building2, MapPin, Link } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import CuitInput from '../Shared/CuitInput';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
company: Company | null;
|
||||
@@ -11,6 +13,7 @@ interface Props {
|
||||
|
||||
export default function CompanyModal({ company, onClose }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCuitValid, setIsCuitValid] = useState(true);
|
||||
const [formData, setFormData] = useState<Partial<Company>>({
|
||||
name: '',
|
||||
taxId: '',
|
||||
@@ -66,11 +69,15 @@ export default function CompanyModal({ company, onClose }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
|
||||
<FileText size={12} /> CUIT / Tax ID
|
||||
</label>
|
||||
<input required type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-mono font-bold text-sm"
|
||||
value={formData.taxId} onChange={e => setFormData({ ...formData, taxId: e.target.value })} />
|
||||
<CuitInput
|
||||
label="CUIT / Tax ID"
|
||||
value={formData.taxId || ''}
|
||||
required
|
||||
onChange={(val, valid) => {
|
||||
setFormData({ ...formData, taxId: val });
|
||||
setIsCuitValid(valid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
|
||||
@@ -103,8 +110,15 @@ export default function CompanyModal({ company, onClose }: Props) {
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2"
|
||||
type="submit"
|
||||
// Bloquear si está cargando O si el CUIT es inválido (y tiene algo escrito)
|
||||
disabled={loading || !isCuitValid || !formData.taxId}
|
||||
className={clsx(
|
||||
"w-full py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
|
||||
loading || !isCuitValid
|
||||
? "bg-slate-300 text-slate-500 cursor-not-allowed shadow-none"
|
||||
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'}
|
||||
</button>
|
||||
|
||||
98
frontend/admin-panel/src/components/Shared/CuitInput.tsx
Normal file
98
frontend/admin-panel/src/components/Shared/CuitInput.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||
import { isValidCuit, formatCuit } from '../../utils/cuitValidator';
|
||||
import { ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CuitInputProps {
|
||||
value: string;
|
||||
onChange: (value: string, isValid: boolean) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
label?: string; // Opcional, para renderizar el label dentro del componente
|
||||
}
|
||||
|
||||
export default function CuitInput({
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
placeholder = "20-12345678-9",
|
||||
className,
|
||||
label
|
||||
}: CuitInputProps) {
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
|
||||
// Validar al montar o cuando cambia el value externamente
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setIsValid(isValidCuit(value));
|
||||
} else {
|
||||
setIsValid(!required); // Si está vacío y no es requerido, es válido
|
||||
}
|
||||
}, [value, required]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value;
|
||||
const formatted = formatCuit(rawValue);
|
||||
|
||||
// Solo permitir hasta 13 caracteres (11 dígitos + 2 guiones)
|
||||
if (formatted.length > 13) return;
|
||||
|
||||
const valid = isValidCuit(formatted);
|
||||
|
||||
setIsTouched(true);
|
||||
setIsValid(valid);
|
||||
|
||||
// Propagamos el cambio al padre
|
||||
onChange(formatted, valid);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsTouched(true);
|
||||
};
|
||||
|
||||
const showError = isTouched && !isValid && value.length > 0;
|
||||
const showSuccess = isValid && value.length === 13;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-1.5 flex items-center gap-1">
|
||||
{label}
|
||||
{required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className={clsx(
|
||||
"w-full p-3 bg-slate-50 border-2 rounded-xl outline-none font-mono font-bold text-sm transition-all pr-10",
|
||||
showError
|
||||
? "border-rose-300 text-rose-700 focus:border-rose-500 bg-rose-50"
|
||||
: showSuccess
|
||||
? "border-emerald-300 text-emerald-700 focus:border-emerald-500 bg-emerald-50/30"
|
||||
: "border-slate-100 text-slate-700 focus:border-blue-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Icono de estado absoluto a la derecha */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{showError && <ShieldAlert size={18} className="text-rose-500 animate-pulse" />}
|
||||
{showSuccess && <ShieldCheck size={18} className="text-emerald-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<p className="text-[10px] font-bold text-rose-500 mt-1 ml-1 uppercase tracking-tight">
|
||||
CUIT inválido (Verifique dígito verificador)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/admin-panel/src/utils/cuitValidator.ts
Normal file
43
frontend/admin-panel/src/utils/cuitValidator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Valida un CUIT/CUIL argentino aplicando el algoritmo de Módulo 11.
|
||||
* Soporta formatos con o sin guiones (20-12345678-9 o 20123456789).
|
||||
*/
|
||||
export const isValidCuit = (cuit: string): boolean => {
|
||||
if (!cuit) return false;
|
||||
|
||||
// 1. Limpiar el input: dejar solo números
|
||||
const cleanCuit = cuit.replace(/[^0-9]/g, '');
|
||||
|
||||
// 2. Verificar longitud exacta (11 dígitos)
|
||||
if (cleanCuit.length !== 11) return false;
|
||||
|
||||
// 3. Convertir a array de números
|
||||
const digits = cleanCuit.split('').map(Number);
|
||||
|
||||
// 4. Algoritmo de verificación (Módulo 11)
|
||||
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
total += digits[i] * multipliers[i];
|
||||
}
|
||||
|
||||
const remainder = total % 11;
|
||||
const calculatedVerifier = remainder === 0 ? 0 : remainder === 1 ? 9 : 11 - remainder;
|
||||
const actualVerifier = digits[10];
|
||||
|
||||
// Caso especial: Cuando el resto es 1, el resultado suele ser 9 en algunos casos específicos
|
||||
// o se considera inválido y se debe cambiar el prefijo (Hombres/Mujeres),
|
||||
// pero para validación estricta matemática:
|
||||
return calculatedVerifier === actualVerifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea un string de números al formato XX-XXXXXXXX-X
|
||||
*/
|
||||
export const formatCuit = (cuit: string): string => {
|
||||
const nums = cuit.replace(/[^0-9]/g, '');
|
||||
if (nums.length <= 2) return nums;
|
||||
if (nums.length <= 10) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
|
||||
return `${nums.slice(0, 2)}-${nums.slice(2, 10)}-${nums.slice(10, 11)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user