Feat ERP 3

This commit is contained in:
2026-02-21 19:23:17 -03:00
parent 29aa8e30e7
commit 841cc961b5
13 changed files with 835 additions and 26 deletions

View File

@@ -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>

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

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