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)}`;
|
||||
};
|
||||
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 [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],
|
||||
isFeatured: false, allowContact: false
|
||||
});
|
||||
@@ -260,7 +260,7 @@ export default function FastEntryPage() {
|
||||
}
|
||||
}, [debouncedClientSearch, showSuggestions]);
|
||||
|
||||
const handlePaymentConfirm = async (payments: Payment[]) => {
|
||||
const handlePaymentConfirm = async (payments: Payment[], _isCreditSale: boolean) => {
|
||||
try {
|
||||
const listingRes = await api.post('/listings', {
|
||||
categoryId: parseInt(formData.categoryId),
|
||||
@@ -308,6 +308,7 @@ export default function FastEntryPage() {
|
||||
price: '',
|
||||
clientName: '',
|
||||
clientDni: '',
|
||||
clientId: null,
|
||||
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
||||
isFeatured: false,
|
||||
allowContact: false
|
||||
@@ -324,7 +325,7 @@ export default function FastEntryPage() {
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -649,7 +650,7 @@ export default function FastEntryPage() {
|
||||
</motion.div>
|
||||
|
||||
{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 type { CreateOrderRequest } from '../types/Order';
|
||||
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 ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||
import ClientCreateModal from '../components/POS/ClientCreateModal';
|
||||
import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
export default function UniversalPosPage() {
|
||||
const { showToast } = useToast();
|
||||
@@ -23,6 +24,8 @@ export default function UniversalPosPage() {
|
||||
// Estados de Modales
|
||||
const [showAdEditor, setShowAdEditor] = useState(false);
|
||||
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)
|
||||
const [addingProduct, setAddingProduct] = useState(false);
|
||||
@@ -56,17 +59,6 @@ export default function UniversalPosPage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [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) => {
|
||||
setAddingProduct(true);
|
||||
try {
|
||||
@@ -122,6 +114,29 @@ export default function UniversalPosPage() {
|
||||
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) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
@@ -297,6 +312,27 @@ export default function UniversalPosPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user