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 { useState, useEffect } from 'react';
import type { Company } from '../../types/Company'; import type { Company } from '../../types/Company';
import { companyService } from '../../services/companyService'; 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 { motion } from 'framer-motion';
import CuitInput from '../Shared/CuitInput';
import clsx from 'clsx';
interface Props { interface Props {
company: Company | null; company: Company | null;
@@ -11,6 +13,7 @@ interface Props {
export default function CompanyModal({ company, onClose }: Props) { export default function CompanyModal({ company, onClose }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isCuitValid, setIsCuitValid] = useState(true);
const [formData, setFormData] = useState<Partial<Company>>({ const [formData, setFormData] = useState<Partial<Company>>({
name: '', name: '',
taxId: '', taxId: '',
@@ -66,11 +69,15 @@ export default function CompanyModal({ company, onClose }: Props) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <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"> <CuitInput
<FileText size={12} /> CUIT / Tax ID label="CUIT / Tax ID"
</label> value={formData.taxId || ''}
<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" required
value={formData.taxId} onChange={e => setFormData({ ...formData, taxId: e.target.value })} /> onChange={(val, valid) => {
setFormData({ ...formData, taxId: val });
setIsCuitValid(valid);
}}
/>
</div> </div>
<div className="space-y-1.5"> <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"> <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"> <div className="pt-4">
<button <button
type="submit" disabled={loading} type="submit"
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" // 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'} <Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'}
</button> </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)}`;
};

View File

@@ -0,0 +1,186 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { X, Save, User, MapPin, Mail, Phone, CreditCard, FileText } from 'lucide-react';
import CuitInput from '../Shared/CuitInput';
import { clientService, type CreateClientRequest } from '../../services/clientService';
import { useToast } from '../../context/use-toast';
import clsx from 'clsx';
interface Props {
onClose: () => void;
onSuccess: (client: { id: number; name: string; dniOrCuit: string }) => void;
initialCuit?: string; // Por si el cajero buscó un CUIT y no existía, precargarlo
}
export default function ClientCreateModal({ onClose, onSuccess, initialCuit = '' }: Props) {
const { showToast } = useToast();
const [loading, setLoading] = useState(false);
// Estado del formulario
const [formData, setFormData] = useState<CreateClientRequest>({
name: '',
dniOrCuit: initialCuit,
email: '',
phone: '',
address: '',
taxType: 'Consumidor Final'
});
const [isCuitValid, setIsCuitValid] = useState(!!initialCuit);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isCuitValid) return showToast("El CUIT ingresado no es válido", "error");
setLoading(true);
try {
const newClient = await clientService.create(formData);
showToast("Cliente creado exitosamente", "success");
onSuccess(newClient);
onClose();
} catch (error: any) {
console.error(error);
const msg = error.response?.data || "Error al crear cliente";
showToast(msg, "error");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[400] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white w-full max-w-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col"
>
{/* Header */}
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<div>
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<User className="text-blue-600" /> Alta Rápida de Cliente
</h3>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-8">Registro Express para Facturación</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* CUIT - Componente Reutilizable */}
<div className="md:col-span-1">
<CuitInput
label="DNI / CUIT"
value={formData.dniOrCuit}
required
onChange={(val, valid) => {
setFormData({ ...formData, dniOrCuit: val });
setIsCuitValid(valid);
}}
/>
</div>
{/* Condición Fiscal */}
<div className="md:col-span-1 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} /> Condición Fiscal
</label>
<select
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm text-slate-700 appearance-none"
value={formData.taxType}
onChange={e => setFormData({ ...formData, taxType: e.target.value })}
>
<option value="Consumidor Final">Consumidor Final</option>
<option value="IVA Responsable Inscripto">Responsable Inscripto</option>
<option value="Monotributista">Monotributista</option>
<option value="IVA Exento">IVA Exento</option>
<option value="No Alcanzado">No Alcanzado</option>
</select>
</div>
{/* Razón Social */}
<div className="md:col-span-2 space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<CreditCard size={12} /> Razón Social / Nombre Completo <span className="text-rose-500">*</span>
</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-black text-slate-800 text-lg uppercase"
placeholder="Ej: EMPRESA S.A. o JUAN PEREZ"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
{/* Dirección */}
<div className="md:col-span-2 space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<MapPin size={12} /> Dirección de Facturación
</label>
<input
type="text"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="Calle, Altura, Localidad"
value={formData.address || ''}
onChange={e => setFormData({ ...formData, address: e.target.value })}
/>
</div>
{/* Email */}
<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">
<Mail size={12} /> Correo Electrónico
</label>
<input
type="email"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="contacto@cliente.com"
value={formData.email || ''}
onChange={e => setFormData({ ...formData, email: e.target.value })}
/>
</div>
{/* Teléfono */}
<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">
<Phone size={12} /> Teléfono
</label>
<input
type="text"
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm text-slate-700"
placeholder="Cod. Area + Número"
value={formData.phone || ''}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
{/* Footer Actions */}
<div className="pt-4 flex gap-4 border-t border-slate-100">
<button
type="button"
onClick={onClose}
className="flex-1 py-4 bg-white border-2 border-slate-200 text-slate-500 font-black uppercase text-xs tracking-widest rounded-xl hover:bg-slate-50 transition-all"
>
Cancelar
</button>
<button
type="submit"
disabled={loading || !isCuitValid || !formData.name}
className={clsx(
"flex-[2] py-4 rounded-xl font-black uppercase text-xs tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
loading || !isCuitValid || !formData.name
? "bg-slate-300 text-white cursor-not-allowed shadow-none"
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
)}
>
<Save size={16} /> {loading ? 'Registrando...' : 'Crear Cliente'}
</button>
</div>
</form>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { useState, useEffect, useRef } from 'react';
import { Search, User, Plus, X, ArrowRight, CreditCard, ShieldAlert } from 'lucide-react';
import { clientService } from '../../services/clientService';
import { useDebounce } from '../../hooks/useDebounce';
import { motion } from 'framer-motion';
import clsx from 'clsx';
interface ClientSearchResult {
id: number;
name: string;
dniOrCuit: string;
taxType: string;
isCreditBlocked?: boolean; // Si el backend lo devuelve en la búsqueda
}
interface Props {
onClose: () => void;
onSelect: (client: { id: number; name: string }) => void;
onCreateNew: () => void; // Callback para switchear al modal de creación
}
export default function ClientSearchModal({ onClose, onSelect, onCreateNew }: Props) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const [results, setResults] = useState<ClientSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Carga inicial y búsqueda
useEffect(() => {
setLoading(true);
clientService.getAll(debouncedQuery)
.then((data: any) => {
setResults(data);
setSelectedIndex(0); // Reset selección al buscar
})
.catch(console.error)
.finally(() => setLoading(false));
}, [debouncedQuery]);
// Manejo de Teclado
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => (prev - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (results.length > 0) {
handleSelect(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [results, selectedIndex]);
const handleSelect = (client: ClientSearchResult) => {
onSelect({ id: client.id, name: client.name });
onClose();
};
return (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[300] flex items-start justify-center pt-20 p-4">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="bg-white w-full max-w-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col max-h-[80vh]"
>
{/* Header con Buscador */}
<div className="p-6 border-b border-slate-100 bg-slate-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
<User className="text-blue-600" size={18} /> Buscar Cliente
</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={20} /></button>
</div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
<input
ref={inputRef}
autoFocus
type="text"
placeholder="Escriba Nombre, Razón Social, DNI o CUIT..."
className="w-full pl-12 pr-4 py-4 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 font-bold text-lg text-slate-800 placeholder:text-slate-300 transition-all"
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
)}
</div>
</div>
{/* Lista de Resultados */}
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar bg-white min-h-[300px]">
{results.length === 0 && !loading ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400 gap-3">
<Search size={48} className="opacity-20" />
<p className="text-xs font-bold uppercase tracking-widest">No se encontraron clientes</p>
<button
onClick={onCreateNew}
className="mt-2 bg-blue-600 text-white px-6 py-2 rounded-xl font-black text-xs uppercase tracking-widest hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Plus size={16} /> Crear Nuevo Cliente
</button>
</div>
) : (
<div className="space-y-1">
{results.map((client, idx) => (
<div
key={client.id}
onClick={() => handleSelect(client)}
className={clsx(
"p-4 rounded-xl cursor-pointer transition-all flex justify-between items-center group",
idx === selectedIndex
? "bg-blue-600 text-white shadow-md transform scale-[1.01]"
: "hover:bg-slate-50 text-slate-700"
)}
>
<div className="flex items-center gap-4">
<div className={clsx(
"w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm",
idx === selectedIndex ? "bg-white/20 text-white" : "bg-slate-100 text-slate-500"
)}>
{client.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-black uppercase text-sm leading-tight">{client.name}</div>
<div className={clsx("text-[10px] font-mono mt-1 flex items-center gap-2", idx === selectedIndex ? "text-blue-100" : "text-slate-400")}>
<CreditCard size={10} /> {client.dniOrCuit}
<span className="opacity-50"></span>
<span>{client.taxType || 'Consumidor Final'}</span>
</div>
</div>
</div>
{/* Indicador visual si está seleccionado o bloqueado */}
<div className="flex items-center gap-3">
{client.isCreditBlocked && (
<div className={clsx("px-2 py-1 rounded text-[8px] font-black uppercase flex items-center gap-1", idx === selectedIndex ? "bg-rose-500/20 text-white" : "bg-rose-100 text-rose-600")}>
<ShieldAlert size={10} /> Bloqueado
</div>
)}
<ArrowRight size={18} className={clsx("transition-transform", idx === selectedIndex ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2")} />
</div>
</div>
))}
</div>
)}
</div>
{/* Footer con Ayuda */}
<div className="p-3 bg-slate-50 border-t border-slate-100 flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider px-6">
<div className="flex gap-4">
<span> Navegar</span>
<span>ENTER Seleccionar</span>
</div>
<button
onClick={onCreateNew}
className="flex items-center gap-1 hover:text-blue-600 transition-colors"
>
<Plus size={12} /> Crear Nuevo Cliente
</button>
</div>
</motion.div>
</div>
);
}

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

@@ -54,7 +54,7 @@ export default function FastEntryPage() {
const catWrapperRef = useRef<HTMLDivElement>(null); const catWrapperRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0], startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false, allowContact: false isFeatured: false, allowContact: false
}); });
@@ -260,7 +260,7 @@ export default function FastEntryPage() {
} }
}, [debouncedClientSearch, showSuggestions]); }, [debouncedClientSearch, showSuggestions]);
const handlePaymentConfirm = async (payments: Payment[]) => { const handlePaymentConfirm = async (payments: Payment[], _isCreditSale: boolean) => {
try { try {
const listingRes = await api.post('/listings', { const listingRes = await api.post('/listings', {
categoryId: parseInt(formData.categoryId), categoryId: parseInt(formData.categoryId),
@@ -308,6 +308,7 @@ export default function FastEntryPage() {
price: '', price: '',
clientName: '', clientName: '',
clientDni: '', clientDni: '',
clientId: null,
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0], startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false, isFeatured: false,
allowContact: false allowContact: false
@@ -324,7 +325,7 @@ export default function FastEntryPage() {
}; };
const handleSelectClient = (client: Client) => { const handleSelectClient = (client: Client) => {
setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit })); setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit, clientId: client.id }));
setShowSuggestions(false); setShowSuggestions(false);
}; };
@@ -649,7 +650,7 @@ export default function FastEntryPage() {
</motion.div> </motion.div>
{showPaymentModal && ( {showPaymentModal && (
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} /> <PaymentModal totalAmount={pricing.totalPrice} clientId={formData.clientId} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
)} )}
</> </>
); );

View File

@@ -9,8 +9,9 @@ import PaymentModal, { type Payment } from '../components/PaymentModal';
import { orderService } from '../services/orderService'; import { orderService } from '../services/orderService';
import type { CreateOrderRequest } from '../types/Order'; import type { CreateOrderRequest } from '../types/Order';
import AdEditorModal from '../components/POS/AdEditorModal'; import AdEditorModal from '../components/POS/AdEditorModal';
// Importamos el componente de búsqueda de clientes para el modal (Asumiremos que existe o usamos un simple prompt por ahora para no extender demasiado, idealmente ClientSearchModal) import ClientCreateModal from '../components/POS/ClientCreateModal';
// import ClientSearchModal from '../components/POS/ClientSearchModal'; import ClientSearchModal from '../components/POS/ClientSearchModal';
import { AnimatePresence } from 'framer-motion';
export default function UniversalPosPage() { export default function UniversalPosPage() {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -23,6 +24,8 @@ export default function UniversalPosPage() {
// Estados de Modales // Estados de Modales
const [showAdEditor, setShowAdEditor] = useState(false); const [showAdEditor, setShowAdEditor] = useState(false);
const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null); const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null);
const [showCreateClient, setShowCreateClient] = useState(false);
const [showClientSearch, setShowClientSearch] = useState(false);
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos) // Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
const [addingProduct, setAddingProduct] = useState(false); const [addingProduct, setAddingProduct] = useState(false);
@@ -56,17 +59,6 @@ export default function UniversalPosPage() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco }, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco
const handleChangeClient = () => {
// Aquí abriríamos el ClientSearchModal.
// Para no bloquear, simulamos un cambio rápido o un prompt simple si no hay modal aún.
// En producción: setShowClientModal(true);
const id = prompt("Ingrese ID de Cliente (Simulación F7):", "1003");
if (id) {
// Buscar nombre real en API...
setClient(parseInt(id), "Cliente #" + id);
}
};
const handleProductSelect = async (product: Product) => { const handleProductSelect = async (product: Product) => {
setAddingProduct(true); setAddingProduct(true);
try { try {
@@ -122,6 +114,29 @@ export default function UniversalPosPage() {
setShowPayment(true); setShowPayment(true);
}; };
const handleChangeClient = () => {
setShowClientSearch(true);
};
// Callback cuando seleccionan del buscador
const handleClientSelected = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
// showClientSearch se cierra automáticamente por el componente, o lo forzamos aquí si es necesario
// El componente ClientSearchModal llama a onClose internamente después de onSelect
};
// Callback cuando crean uno nuevo
const handleClientCreated = (client: { id: number; name: string }) => {
setClient(client.id, client.name);
setShowCreateClient(false);
};
// Función puente: Del Buscador -> Al Creador
const switchToCreate = () => {
setShowClientSearch(false);
setTimeout(() => setShowCreateClient(true), 100); // Pequeño delay para transición suave
};
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => { const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
setIsProcessing(true); setIsProcessing(true);
try { try {
@@ -297,6 +312,27 @@ export default function UniversalPosPage() {
clientId={clientId || 1005} clientId={clientId || 1005}
/> />
)} )}
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
<AnimatePresence>
{showClientSearch && (
<ClientSearchModal
onClose={() => setShowClientSearch(false)}
onSelect={handleClientSelected}
onCreateNew={switchToCreate}
/>
)}
</AnimatePresence>
{/* MODAL DE ALTA RÁPIDA */}
<AnimatePresence>
{showCreateClient && (
<ClientCreateModal
onClose={() => setShowCreateClient(false)}
onSuccess={handleClientCreated}
/>
)}
</AnimatePresence>
</div> </div>
); );
} }

View File

@@ -0,0 +1,33 @@
// src/services/clientService.ts
import api from './api';
export interface CreateClientRequest {
name: string;
dniOrCuit: string;
email?: string;
phone?: string;
address?: string;
taxType: string;
}
export const clientService = {
getAll: async (q?: string) => {
const res = await api.get('/clients', { params: { q } });
return res.data;
},
getSummary: async (id: number) => {
const res = await api.get(`/clients/${id}/summary`);
return res.data;
},
update: async (id: number, clientData: any) => {
await api.put(`/clients/${id}`, clientData);
},
resetPassword: async (id: number) => {
const res = await api.post(`/clients/${id}/reset-password`);
return res.data;
},
create: async (data: CreateClientRequest) => {
const res = await api.post('/clients', data);
return res.data; // Retorna { id, name, dniOrCuit }
}
};

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

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities; using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories; using SIGCM.Infrastructure.Repositories;
@@ -90,4 +91,51 @@ public class ClientsController : ControllerBase
return Ok(new { message = "Contraseña restablecida a '1234' correctamente." }); return Ok(new { message = "Contraseña restablecida a '1234' correctamente." });
} }
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateClientDto dto)
{
// 1. Validar si ya existe (por CUIT)
// Usamos el repo de usuarios porque los clientes son usuarios
var existing = await _repo.SearchAsync(dto.DniOrCuit);
if (existing.Any(c => c.DniOrCuit == dto.DniOrCuit))
{
return BadRequest("Ya existe un cliente registrado con este DNI/CUIT.");
}
// 2. Mapeo a Entidad User (Modelo de DB)
// Nota: El repositorio ClientRepository maneja la inserción en la tabla Users
// Vamos a crear un método específico en el repo para esto o usar el existente mejorado.
var client = new Client // Usamos la entidad de dominio Client como DTO de transporte al repo
{
Name = dto.Name,
DniOrCuit = dto.DniOrCuit,
Email = dto.Email,
Phone = dto.Phone,
Address = dto.Address,
TaxType = dto.TaxType,
IsActive = true
};
// Llamamos a un nuevo método en el repo para creación completa
var id = await _repo.CreateFullClientAsync(client);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CREATE_CLIENT",
EntityId = id,
EntityType = "Client",
Details = $"Alta Rápida de Cliente: {dto.Name} ({dto.DniOrCuit})",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { id, name = dto.Name, dniOrCuit = dto.DniOrCuit });
}
} }

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Application.DTOs;
public class CreateClientDto
{
public required string Name { get; set; } // Razón Social o Nombre
public required string DniOrCuit { get; set; } // Validado por frontend
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string TaxType { get; set; } = "Consumidor Final";
}

View File

@@ -136,4 +136,24 @@ public class ClientRepository
var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id"; var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id";
await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId }); await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId });
} }
public async Task<int> CreateFullClientAsync(Client client)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO Users (
Username, PasswordHash, Role,
BillingName, BillingTaxId, Email, Phone, BillingAddress, BillingTaxType,
MustChangePassword, IsActive, CreatedAt
)
VALUES (
@DniOrCuit, 'N/A', 'Client',
@Name, @DniOrCuit, @Email, @Phone, @Address, @TaxType,
0, 1, GETUTCDATE()
);
SELECT CAST(SCOPE_IDENTITY() as int);";
// Usamos el CUIT como username por defecto para garantizar unicidad
return await conn.QuerySingleAsync<int>(sql, client);
}
} }