Feat ERP 3
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
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)}`;
|
||||||
|
};
|
||||||
186
frontend/counter-panel/src/components/POS/ClientCreateModal.tsx
Normal file
186
frontend/counter-panel/src/components/POS/ClientCreateModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/counter-panel/src/components/POS/ClientSearchModal.tsx
Normal file
178
frontend/counter-panel/src/components/POS/ClientSearchModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/counter-panel/src/components/Shared/CuitInput.tsx
Normal file
98
frontend/counter-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
33
frontend/counter-panel/src/services/clientService.ts
Normal file
33
frontend/counter-panel/src/services/clientService.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
};
|
||||||
43
frontend/counter-panel/src/utils/cuitValidator.ts
Normal file
43
frontend/counter-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)}`;
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
11
src/SIGCM.Application/DTOs/ClientDtos.cs
Normal file
11
src/SIGCM.Application/DTOs/ClientDtos.cs
Normal 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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user