Feat Varios 4

This commit is contained in:
2026-01-06 14:20:44 -03:00
parent 9fa21ebec3
commit c6496099bd
16 changed files with 613 additions and 208 deletions

14
HashGen/HashGen.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
</ItemGroup>
</Project>

1
HashGen/Program.cs Normal file
View File

@@ -0,0 +1 @@
System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234"));

View File

@@ -15,6 +15,10 @@ interface Client {
email?: string;
phone?: string;
address?: string;
taxType?: string;
username: string;
isActive: boolean;
role: string;
totalAds: number;
totalSpent: number;
}
@@ -32,12 +36,17 @@ export default function ClientManager() {
const [showSummaryModal, setShowSummaryModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadClients(); }, []);
useEffect(() => {
const timer = setTimeout(() => {
loadClients(search);
}, 400); // 400ms debounce
return () => clearTimeout(timer);
}, [search]);
const loadClients = async () => {
const loadClients = async (query?: string) => {
setLoading(true);
try {
const res = await clientService.getAll();
const res = await clientService.getAll(query);
setClients(res);
} finally {
setLoading(false);
@@ -53,7 +62,7 @@ export default function ClientManager() {
};
const handleOpenEdit = (client: Client) => {
setSelectedClient({ ...client });
setSelectedClient({ ...client, taxType: client.taxType || "Consumidor Final" });
setShowEditModal(true);
};
@@ -63,144 +72,215 @@ export default function ClientManager() {
setIsSaving(true);
try {
await clientService.update(selectedClient.id, selectedClient);
await loadClients();
await loadClients(search);
setShowEditModal(false);
} finally {
setIsSaving(false);
}
};
const filteredClients = clients.filter(c => {
const name = (c.name || "").toLowerCase();
const dni = (c.dniOrCuit || "").toLowerCase();
const s = search.toLowerCase();
return name.includes(s) || dni.includes(s);
});
return (
<div className="space-y-6">
<header className="flex justify-between items-center">
<header className="flex justify-between items-center px-2">
<div>
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
<User className="text-blue-600" /> Directorio de Clientes
<h2 className="text-3xl font-black text-slate-900 tracking-tight flex items-center gap-3">
<span className="w-12 h-12 bg-blue-600/10 text-blue-600 rounded-2xl flex items-center justify-center shadow-sm">
<User size={24} />
</span>
Directorio de Clientes
</h2>
<p className="text-sm text-gray-500">Gestión de datos fiscales y analítica de anunciantes</p>
<p className="text-sm font-bold text-slate-400 mt-1 ml-15">Gestión de datos fiscales y analítica de anunciantes</p>
</div>
</header>
<div className="bg-white p-4 rounded-xl border shadow-sm relative">
<Search className="absolute left-7 top-7 text-gray-400" size={20} />
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm relative group transition-all hover:shadow-md">
<Search className="absolute left-10 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-blue-500 transition-colors" size={20} />
<input
type="text"
placeholder="Filtrar por nombre o identificación..."
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all"
placeholder="Buscar anunciante por nombre, DNI o CUIT..."
className="w-full pl-14 pr-6 py-4 bg-slate-50 border-transparent border-2 rounded-2xl outline-none focus:border-blue-500/20 focus:bg-white text-sm font-bold text-slate-700 transition-all placeholder:text-slate-300"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<div className="absolute right-8 top-1/2 -translate-y-1/2 text-[10px] font-black text-blue-500 opacity-50 uppercase tracking-widest">
Filtrando en servidor...
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full py-20 text-center animate-pulse text-gray-400 font-bold uppercase text-xs tracking-widest">
Sincronizando base de datos...
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{loading && clients.length === 0 ? (
<div className="col-span-full py-40 text-center flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<p className="font-black text-slate-300 uppercase text-[10px] tracking-widest">Sincronizando base de datos...</p>
</div>
) : filteredClients.map(client => (
<div key={client.id} className="bg-white rounded-2xl border border-gray-200 shadow-sm hover:shadow-md transition-all overflow-hidden group">
<div className="p-5 border-b border-gray-50 bg-gray-50/50">
<div className="flex justify-between items-start">
<div className="w-10 h-10 bg-blue-600 text-white rounded-xl flex items-center justify-center font-bold text-lg">
) : clients.length === 0 ? (
<div className="col-span-full py-20 text-center">
<div className="text-slate-300 font-black text-xs uppercase tracking-[0.3em]">No se encontraron clientes que coincidan</div>
</div>
) : clients.map(client => (
<div key={client.id} className={`bg-white rounded-[1.8rem] border border-slate-100 shadow-sm hover:shadow-lg transition-all overflow-hidden flex flex-col group ${!client.isActive ? 'opacity-60 saturate-50' : ''}`}>
<div className="p-5 border-b border-slate-50 bg-slate-50/20 relative">
<button
onClick={() => handleOpenEdit(client)}
className="absolute top-4 right-4 p-2 text-slate-300 hover:text-blue-600 hover:bg-white rounded-lg transition-all shadow-sm"
>
<Edit size={16} />
</button>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 bg-gradient-to-br ${!client.isActive ? 'from-slate-400 to-slate-600' : 'from-blue-600 to-indigo-700'} text-white rounded-xl flex items-center justify-center font-black text-lg shadow-md shadow-blue-500/10`}>
{(client.name || "?").charAt(0).toUpperCase()}
</div>
<button
onClick={() => handleOpenEdit(client)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Edit size={18} />
</button>
</div>
<h3 className="mt-3 font-bold text-gray-800 text-lg line-clamp-1 uppercase tracking-tight">{client.name}</h3>
<div className="flex items-center gap-1.5 text-xs text-gray-400 font-bold mt-1">
<CreditCard size={14} /> {client.dniOrCuit}
<div className="min-w-0 flex-1">
<h3 className="font-black text-slate-900 text-[13px] line-clamp-1 uppercase tracking-tight leading-tight">{client.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[9px] font-bold text-slate-400 font-mono tracking-tighter">@{client.username}</span>
<span className="w-1 h-1 bg-slate-200 rounded-full"></span>
<span className="text-[9px] font-bold text-slate-400 font-mono">{client.dniOrCuit}</span>
</div>
</div>
</div>
</div>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail size={16} className="text-gray-300" /> {client.email || 'N/A'}
<div className="p-5 flex-1 space-y-2.5">
<div className="flex items-center gap-2.5 text-[11px] font-bold text-slate-500">
<Mail size={12} className="text-slate-300 shrink-0" />
<span className="truncate">{client.email || 'Sin correo'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone size={16} className="text-gray-300" /> {client.phone || 'N/A'}
<div className="flex items-center gap-2.5 text-[11px] font-bold text-slate-500">
<Phone size={12} className="text-slate-300 shrink-0" />
<span>{client.phone || 'Sin tel.'}</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-4 pt-4 border-t border-gray-50">
<div className="text-center border-r border-gray-50">
<div className="text-[9px] text-gray-400 font-black uppercase tracking-tighter">Avisos</div>
<div className="text-lg font-black text-slate-800">{client.totalAds}</div>
<div className="grid grid-cols-2 gap-2 mt-3 pt-3 border-t border-slate-50">
<div className="text-[10px] font-black text-slate-800 flex justify-between">
<span className="text-slate-400 uppercase tracking-tighter">Avisos</span>
<span>{client.totalAds}</span>
</div>
<div className="text-center">
<div className="text-[9px] text-gray-400 font-black uppercase tracking-tighter">Invertido</div>
<div className="text-lg font-black text-emerald-600">${client.totalSpent?.toLocaleString()}</div>
<div className="text-[10px] font-black text-emerald-600 flex justify-between border-l pl-2 border-slate-100">
<span className="text-slate-400 uppercase tracking-tighter">Invertido</span>
<span>${client.totalSpent > 1000 ? Math.round(client.totalSpent / 1000) + 'k' : client.totalSpent}</span>
</div>
</div>
</div>
<button
onClick={() => handleOpenSummary(client)}
className="w-full py-3.5 bg-white text-blue-600 text-[10px] font-black uppercase tracking-[0.2em] hover:bg-blue-600 hover:text-white transition-all flex items-center justify-center gap-2 border-t"
className="w-full py-3 bg-slate-900 text-white text-[9px] font-black uppercase tracking-[0.2em] hover:bg-blue-600 transition-all flex items-center justify-center gap-2"
>
<BarChart3 size={14} /> Ficha del Anunciante
<BarChart3 size={13} /> Ficha del Anunciante
</button>
</div>
))}
</div>
{/* --- MODAL DE EDICIÓN / FACTURACIÓN --- */}
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Datos Fiscales y de Contacto">
<form onSubmit={handleSaveEdit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Razón Social / Nombre</label>
<input
type="text" required
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.name || ""}
onChange={e => setSelectedClient({ ...selectedClient!, name: e.target.value })}
/>
</div>
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Actualizar Perfil de Cliente">
<form onSubmit={handleSaveEdit} className="space-y-6">
<div className="bg-blue-50/50 p-4 rounded-2xl flex items-center justify-between">
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">DNI / CUIT</label>
<input
type="text" required
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.dniOrCuit || ""}
onChange={e => setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })}
/>
</div>
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Teléfono</label>
<input
type="text"
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.phone || ""}
onChange={e => setSelectedClient({ ...selectedClient!, phone: e.target.value })}
/>
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest leading-none mb-1">Estado de la Cuenta</p>
<p className="text-sm font-black text-blue-900">{selectedClient?.isActive ? 'ACTIVA Y OPERATIVA' : 'CUENTA DESACTIVADA'}</p>
</div>
<button
type="button"
onClick={() => setSelectedClient({ ...selectedClient, isActive: !selectedClient.isActive })}
className={`w-12 h-6 rounded-full p-1 transition-all ${selectedClient?.isActive ? 'bg-blue-600' : 'bg-slate-300'}`}
>
<div className={`w-4 h-4 bg-white rounded-full transition-all ${selectedClient?.isActive ? 'translate-x-6' : 'translate-x-0'}`}></div>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Email</label>
<input
type="email"
className="w-full border-2 border-gray-100 p-2.5 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
value={selectedClient?.email || ""}
onChange={e => setSelectedClient({ ...selectedClient!, email: e.target.value })}
/>
</div>
<div className="md:col-span-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Dirección de Facturación</label>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Razón Social / Nombre</label>
<div className="relative">
<MapPin className="absolute left-3 top-3 text-gray-300" size={18} />
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input
type="text" required
className="w-full bg-slate-50 border-2 border-slate-50 pl-12 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.name || ""}
onChange={e => setSelectedClient({ ...selectedClient!, name: e.target.value })}
/>
</div>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Nombre de Usuario (@)</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 font-black text-slate-300">@</span>
<input
type="text" required
className="w-full bg-slate-50 border-2 border-slate-50 pl-10 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.username || ""}
onChange={e => setSelectedClient({ ...selectedClient!, username: e.target.value })}
/>
</div>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">DNI / CUIT</label>
<div className="relative">
<CreditCard className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input
type="text" required
className="w-full bg-slate-50 border-2 border-slate-50 pl-12 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.dniOrCuit || ""}
onChange={e => setSelectedClient({ ...selectedClient!, dniOrCuit: e.target.value })}
/>
</div>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Condición Fiscal</label>
<select
className="w-full bg-slate-50 border-2 border-slate-50 px-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all appearance-none cursor-pointer"
value={selectedClient?.taxType || ""}
onChange={e => setSelectedClient({ ...selectedClient!, taxType: e.target.value })}
>
<option value="Consumidor Final">Consumidor Final</option>
<option value="IVA Responsable Inscripto">Resp. Inscripto</option>
<option value="Monotributista">Monotributista</option>
<option value="IVA Exento">Iva Exento</option>
<option value="No Alcanzado">No Alcanzado</option>
</select>
</div>
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Teléfono de Contacto</label>
<div className="relative">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input
type="text"
className="w-full border-2 border-gray-100 p-2.5 pl-10 rounded-xl font-bold focus:border-blue-500 outline-none transition-all"
className="w-full bg-slate-50 border-2 border-slate-50 pl-12 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.phone || ""}
onChange={e => setSelectedClient({ ...selectedClient!, phone: e.target.value })}
/>
</div>
</div>
<div className="md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Correo Electrónico</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input
type="email"
className="w-full bg-slate-50 border-2 border-slate-50 pl-12 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.email || ""}
onChange={e => setSelectedClient({ ...selectedClient!, email: e.target.value })}
/>
</div>
</div>
<div className="md:col-span-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Dirección de Facturación</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300" size={18} />
<input
type="text"
className="w-full bg-slate-50 border-2 border-slate-50 pl-12 pr-4 py-4 rounded-2xl font-black text-slate-700 focus:border-blue-500 focus:bg-white outline-none transition-all"
value={selectedClient?.address || ""}
onChange={e => setSelectedClient({ ...selectedClient!, address: e.target.value })}
placeholder="Calle, Nro, Localidad..."
@@ -208,14 +288,39 @@ export default function ClientManager() {
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-6">
<button type="button" onClick={() => setShowEditModal(false)} className="px-6 py-2.5 text-xs font-bold text-gray-500 uppercase tracking-widest">Cancelar</button>
<div className="flex justify-between items-center pt-8 border-t border-slate-100">
<button
type="submit" disabled={isSaving}
className="bg-blue-600 text-white px-8 py-2.5 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 disabled:opacity-50 transition-all"
type="button"
onClick={async () => {
if (confirm("¿Estás seguro de establecer '1234' como clave temporal para este cliente?")) {
try {
await clientService.resetPassword(selectedClient.id);
alert("Clave restablecida a '1234' correctamente.");
} catch (e) {
alert("Error al restablecer la clave.");
}
}
}}
className="p-4 bg-rose-50 text-rose-500 rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-rose-500 hover:text-white transition-all shadow-sm"
>
{isSaving ? "Actualizando..." : "Guardar Cambios"}
Blanquear Acceso
</button>
<div className="flex gap-4">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] hover:text-slate-900 transition-colors"
>
Cancelar
</button>
<button
type="submit" disabled={isSaving}
className="bg-blue-600 text-white px-10 py-5 rounded-[1.5rem] font-black text-[10px] uppercase tracking-[0.3em] shadow-xl shadow-blue-500/20 hover:bg-blue-700 disabled:opacity-50 transition-all active:scale-95"
>
{isSaving ? "Gaurdando..." : "Guardar Cambios"}
</button>
</div>
</div>
</form>
</Modal>

View File

@@ -2,8 +2,8 @@
import api from './api';
export const clientService = {
getAll: async () => {
const res = await api.get('/clients');
getAll: async (q?: string) => {
const res = await api.get('/clients', { params: { q } });
return res.data;
},
getSummary: async (id: number) => {
@@ -12,5 +12,9 @@ export const clientService = {
},
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;
}
};

View File

@@ -80,75 +80,78 @@ export default function CashClosingModal({ onClose, onComplete }: CashClosingMod
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="bg-white rounded-[2.5rem] shadow-2xl max-w-lg w-full overflow-hidden border border-white/20"
className="bg-white rounded-[2rem] shadow-2xl max-w-lg w-full overflow-hidden border border-white/20 flex flex-col max-h-[95vh]"
>
{/* Header */}
<div className="bg-slate-900 p-8 text-white relative">
<div className="bg-slate-900 px-8 py-6 text-white relative shrink-0">
<div className="flex justify-between items-center relative z-10">
<div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400">Finalización de Turno</span>
<h2 className="text-3xl font-black tracking-tight">Cierre de Caja</h2>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 block mb-0.5">Control de Tesorería</span>
<h2 className="text-2xl font-black tracking-tight uppercase leading-none">Cierre de Caja</h2>
</div>
{!done && (
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-xl transition-colors">
<X size={24} />
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-xl transition-colors text-slate-400 hover:text-white">
<X size={20} />
</button>
)}
</div>
</div>
<div className="p-8">
<div className="p-6 md:p-8 overflow-y-auto custom-scrollbar flex-1">
<AnimatePresence mode="wait">
{!done ? (
<motion.div key="summary" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
<motion.div key="summary" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-5">
{/* Visualización de Totales del Sistema */}
<div className="space-y-3">
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Resumen de Valores en Caja</h4>
<div className="grid grid-cols-1 gap-2">
<SummaryRow label="Fondo de Apertura" value={summary.openingBalance} icon={<Banknote size={16} />} />
<SummaryRow label="Ventas Efectivo" value={summary.cashSales} icon={<Banknote size={16} />} isSale />
<SummaryRow label="Ventas Tarjetas" value={summary.cardSales} icon={<CreditCard size={16} />} isSale />
<SummaryRow label="Ventas Transferencia" value={summary.transferSales} icon={<ArrowRightLeft size={16} />} isSale />
<div className="grid grid-cols-2 gap-2">
<SummaryRow label="Apertura" value={summary.openingBalance} icon={<Banknote size={14} />} />
<SummaryRow label="Efectivo" value={summary.cashSales} icon={<Banknote size={14} />} isSale />
<SummaryRow label="Tarjetas" value={summary.cardSales} icon={<CreditCard size={14} />} isSale />
<SummaryRow label="Transferencia" value={summary.transferSales} icon={<ArrowRightLeft size={14} />} isSale />
</div>
</div>
{/* Total a Entregar */}
<div className="bg-slate-900 p-6 rounded-[2rem] flex justify-between items-center text-white shadow-xl">
<div>
<span className="text-[10px] font-black uppercase text-slate-500 block mb-1">Total Final a Entregar</span>
<span className="text-4xl font-mono font-black text-green-400">
<div className="bg-slate-900 p-5 rounded-3xl flex justify-between items-center text-white shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-600/5 rounded-full -mr-16 -mt-16 blur-3xl"></div>
<div className="relative z-10">
<span className="text-[9px] font-black uppercase text-blue-400 block mb-0.5 tracking-widest">Total Final a Entregar</span>
<span className="text-3xl font-mono font-black text-emerald-400 leading-none">
$ {summary.totalExpected.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
</span>
</div>
<CheckCircle2 size={40} className="text-green-500 opacity-20" />
<CheckCircle2 size={32} className="text-emerald-500 opacity-20 relative z-10" />
</div>
{/* Notas opcionales */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase ml-1">Notas u Observaciones</label>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase ml-1 tracking-widest">Notas u Observaciones</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Opcional: aclaraciones sobre el turno..."
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl text-sm font-bold outline-none focus:border-blue-500 transition-all resize-none h-24"
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl text-xs font-bold outline-none focus:border-blue-500 transition-all resize-none h-20"
/>
</div>
<div className="bg-amber-50 p-4 rounded-2xl border border-amber-100 flex gap-3">
<AlertCircle className="text-amber-500 shrink-0" size={18} />
<p className="text-[10px] text-amber-800 font-bold leading-tight uppercase opacity-80">
<div className="bg-amber-50 p-3.5 rounded-2xl border border-amber-100 flex gap-3">
<AlertCircle className="text-amber-500 shrink-0" size={16} />
<p className="text-[9px] text-amber-800 font-bold leading-snug uppercase opacity-80">
Al confirmar, declaras que el dinero físico coincide con este resumen.
</p>
</div>
<button
onClick={handleFinalClose}
disabled={isClosing}
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-xl hover:bg-blue-700 transition-all flex items-center justify-center gap-2 active:scale-95"
>
{isClosing ? 'Procesando...' : 'Confirmar y Cerrar Caja'}
</button>
<div className="pt-2">
<button
onClick={handleFinalClose}
disabled={isClosing}
className="w-full py-4 bg-blue-600 text-white font-black uppercase text-[10px] tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 active:scale-[0.98]"
>
{isClosing ? 'Procesando...' : 'Confirmar y Cerrar Caja'}
</button>
</div>
</motion.div>
) : (
<motion.div key="done" initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-center py-10">
@@ -187,12 +190,12 @@ interface SummaryRowProps {
function SummaryRow({ label, value, icon, isSale }: SummaryRowProps) {
return (
<div className="flex justify-between items-center p-3.5 bg-slate-50 rounded-xl border border-slate-100 group hover:bg-white hover:border-blue-100 transition-all">
<div className="flex items-center gap-3">
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-xl border border-slate-100 group hover:bg-white hover:border-blue-100 transition-all">
<div className="flex items-center gap-2.5">
<div className="text-slate-400 group-hover:text-blue-500 transition-colors">{icon}</div>
<span className="text-[10px] font-black text-slate-500 uppercase tracking-tight">{label}</span>
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tight">{label}</span>
</div>
<span className={clsx("font-mono font-black text-sm", isSale ? "text-slate-800" : "text-slate-400")}>
<span className={clsx("font-mono font-black text-[13px]", isSale ? "text-slate-800" : "text-slate-400")}>
$ {value.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
</span>
</div>

View File

@@ -70,7 +70,7 @@ export default function CounterLayout() {
const menuItems = [
{ path: '/dashboard', label: 'Panel Principal', icon: LayoutDashboard, shortcut: 'F1' },
{ path: '/nuevo-aviso', label: 'Nuevo Aviso', icon: PlusCircle, shortcut: 'F2' },
{ path: '/nuevo-aviso', label: 'Operar Caja', icon: PlusCircle, shortcut: 'F2' },
{ path: '/caja', label: 'Caja Diaria', icon: Banknote, shortcut: 'F4' },
{ path: '/historial', label: 'Consultas', icon: ClipboardList, shortcut: 'F8' },
{ path: '/analitica', label: 'Analítica', icon: TrendingUp, shortcut: 'F6' },

View File

@@ -7,7 +7,13 @@ import {
AlignLeft, AlignCenter, AlignRight, AlignJustify,
Type, Search, ChevronDown, Bold, Square as FrameIcon,
ArrowUpRight,
RefreshCw
RefreshCw,
Calendar,
Image as ImageIcon,
X,
UploadCloud,
MessageSquare,
Star
} from 'lucide-react';
import clsx from 'clsx';
import PaymentModal, { type Payment } from '../components/PaymentModal';
@@ -48,8 +54,13 @@ export default function FastEntryPage() {
const catWrapperRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState({
categoryId: '', operationId: '', text: '', days: 3, clientName: '', clientDni: '',
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '',
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false, allowContact: false
});
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [options, setOptions] = useState({
isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left'
@@ -233,13 +244,13 @@ export default function FastEntryPage() {
days: formData.days,
isBold: options.isBold,
isFrame: options.isFrame,
startDate: new Date().toISOString()
startDate: formData.startDate || new Date().toISOString()
});
setPricing(res.data);
} catch (error) { console.error(error); }
};
calculatePrice();
}, [debouncedText, formData.categoryId, formData.days, options]);
}, [debouncedText, formData.categoryId, formData.days, options, formData.startDate]);
useEffect(() => {
if (debouncedClientSearch.length > 2 && showSuggestions) {
@@ -251,17 +262,17 @@ export default function FastEntryPage() {
const handlePaymentConfirm = async (payments: Payment[]) => {
try {
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
await api.post('/listings', {
const listingRes = await api.post('/listings', {
categoryId: parseInt(formData.categoryId),
operationId: parseInt(formData.operationId),
title: formData.text.substring(0, 40) + '...',
title: formData.title || (formData.text.substring(0, 40) + '...'),
description: formData.text,
price: 0,
price: parseFloat(formData.price) || 0,
adFee: pricing.totalPrice,
status: 'Published',
origin: 'Mostrador',
printText: formData.text,
printStartDate: tomorrow.toISOString(),
printStartDate: formData.startDate,
printDaysCount: formData.days,
isBold: options.isBold,
isFrame: options.isFrame,
@@ -269,12 +280,41 @@ export default function FastEntryPage() {
printAlignment: options.alignment,
clientName: formData.clientName,
clientDni: formData.clientDni,
publicationStartDate: formData.startDate,
isFeatured: formData.isFeatured,
allowContact: formData.allowContact,
payments
});
const listingId = listingRes.data.id;
// Subir imágenes si existen
if (selectedImages.length > 0) {
for (const file of selectedImages) {
const imgFormData = new FormData();
imgFormData.append('file', file);
await api.post(`/images/upload/${listingId}`, imgFormData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
}
}
printCourtesyTicket(formData, pricing);
setTimeout(() => { printPaymentReceipt(formData, pricing, payments); }, 500);
setFormData({ ...formData, text: '', clientName: '', clientDni: '' });
setFormData({
...formData,
text: '',
title: '',
price: '',
clientName: '',
clientDni: '',
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
isFeatured: false,
allowContact: false
});
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
setSelectedImages([]);
setImagePreviews([]);
setShowPaymentModal(false);
setErrors({});
showToast('Aviso procesado correctamente.', 'success');
@@ -288,6 +328,21 @@ export default function FastEntryPage() {
setShowSuggestions(false);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
setSelectedImages(prev => [...prev, ...files]);
const newPreviews = files.map(file => URL.createObjectURL(file));
setImagePreviews(prev => [...prev, ...newPreviews]);
}
};
const removeImage = (index: number) => {
setSelectedImages(prev => prev.filter((_, i) => i !== index));
setImagePreviews(prev => prev.filter((_, i) => i !== index));
};
if (sessionLoading) {
return (
<div className="h-full w-full flex items-center justify-center bg-slate-50">
@@ -299,12 +354,11 @@ export default function FastEntryPage() {
return (
<>
{/* BLOQUEO DE SEGURIDAD: Si no hay sesión, mostramos el modal de apertura */}
<AnimatePresence>
{!session.isOpen && (
<CashOpeningModal
onSuccess={refreshSession}
onCancel={() => navigate('/dashboard')} // Si cancela la apertura, lo sacamos de "Nuevo Aviso"
onCancel={() => navigate('/dashboard')}
/>
)}
</AnimatePresence>
@@ -314,17 +368,14 @@ export default function FastEntryPage() {
animate={{
opacity: 1,
scale: 1,
// Si no hay sesión, aplicamos un filtro de desenfoque y desaturación
filter: session.isOpen ? "blur(0px) grayscale(0)" : "blur(8px) grayscale(1)"
}}
transition={{ duration: 0.7, ease: "easeInOut" }}
className={clsx(
"w-full h-full p-5 flex gap-5 bg-slate-50/50 overflow-hidden max-h-screen",
// Bloqueamos interacciones físicas mientras el modal esté presente
!session.isOpen && "pointer-events-none select-none opacity-40"
)}
>
{/* PANEL IZQUIERDO: FORMULARIO */}
<div className="flex-[7] bg-white rounded-[2rem] shadow-xl shadow-slate-200/50 border border-slate-200 p-6 flex flex-col min-h-0 relative">
<div className="flex justify-between items-center mb-6">
<div>
@@ -337,7 +388,7 @@ export default function FastEntryPage() {
<div className="bg-slate-100 p-2 rounded-xl text-slate-400"><Printer size={18} /></div>
</div>
<div className="flex flex-col gap-6 flex-1 min-h-0">
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="grid grid-cols-2 gap-6">
<div className="relative" ref={catWrapperRef}>
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.categoryId ? "text-rose-500" : "text-slate-500")}>
@@ -345,7 +396,7 @@ export default function FastEntryPage() {
</label>
<div
className={clsx(
"w-full p-3.5 border-2 rounded-xl flex justify-between items-center cursor-pointer transition-all duration-300",
"w-full py-2 px-4 border-2 rounded-xl flex justify-between items-center cursor-pointer transition-all duration-300",
isCatDropdownOpen ? "border-blue-500 bg-blue-50/30" : errors.categoryId ? "border-rose-200 bg-rose-50/50" : "border-slate-100 bg-slate-50/50 hover:border-slate-300"
)}
onClick={() => setIsCatDropdownOpen(!isCatDropdownOpen)}
@@ -376,42 +427,84 @@ export default function FastEntryPage() {
</div>
<div>
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.operationId ? "text-rose-500" : "text-slate-500")}>Operación {errors.operationId && "• Requerido"}</label>
<select className={clsx("w-full p-3.5 border-2 rounded-xl outline-none font-extrabold text-sm tracking-tight transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cpath%20d%3D%22M5%207.5L10%2012.5L15%207.5%22%20stroke%3D%22%2364748B%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22/%3E%3C/svg%3E')] bg-[length:18px_18px] bg-[right_1rem_center] bg-no-repeat", formData.operationId ? "border-slate-100 bg-slate-50 text-slate-800" : errors.operationId ? "border-rose-200 bg-rose-50/50 text-rose-400" : "border-slate-100 bg-slate-50 text-slate-400")} value={formData.operationId} onChange={e => { setFormData({ ...formData, operationId: e.target.value }); setErrors({ ...errors, operationId: false }); }}>
<select className={clsx("w-full py-2 px-4 border-2 rounded-xl outline-none font-extrabold text-sm tracking-tight transition-all appearance-none bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cpath%20d%3D%22M5%207.5L10%2012.5L15%207.5%22%20stroke%3D%22%2364748B%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22/%3E%3C/svg%3E')] bg-[length:18px_18px] bg-[right_1rem_center] bg-no-repeat", formData.operationId ? "border-slate-100 bg-slate-50 text-slate-800" : errors.operationId ? "border-rose-200 bg-rose-50/50 text-rose-400" : "border-slate-100 bg-slate-50 text-slate-400")} value={formData.operationId} onChange={e => { setFormData({ ...formData, operationId: e.target.value }); setErrors({ ...errors, operationId: false }); }}>
<option value="">ELIJA OPERACIÓN...</option>
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
</div>
<div className="flex flex-col flex-1 min-h-0">
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.text ? "text-rose-500" : "text-slate-500")}>Cuerpo del Aviso {errors.text && "• Requerido"}</label>
<div className="relative flex-1 group">
<textarea ref={textInputRef} className={clsx("w-full h-full p-6 border-2 rounded-[1.5rem] resize-none outline-none font-mono text-xl tracking-tighter leading-snug transition-all duration-300", errors.text ? "border-rose-200 bg-rose-50/30" : "border-slate-100 bg-slate-50/30 group-focus-within:border-blue-400 group-focus-within:bg-white text-slate-800")} placeholder="ESCRIBA EL TEXTO AQUÍ PARA IMPRENTA..." value={formData.text} onChange={e => { setFormData({ ...formData, text: e.target.value }); setErrors({ ...errors, text: false }); }}></textarea>
<div className="absolute top-3 right-3"><div className="bg-white/80 backdrop-blur px-2 py-1 rounded-lg border border-slate-100 shadow-sm text-[9px] font-black text-slate-400 uppercase tracking-widest">F10 para Cobrar</div></div>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-8">
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Título Web (Opcional)</label>
<input
type="text"
className="w-full py-2 px-4 border-2 border-slate-100 bg-slate-50/50 rounded-xl outline-none focus:border-blue-500 font-extrabold text-sm tracking-tight placeholder:text-slate-400"
placeholder="SI SE DEJA VACÍO SE GENERA DEL TEXTO..."
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
/>
</div>
<div className="flex justify-between items-center mt-3 bg-slate-900 px-5 py-2.5 rounded-xl shadow-lg">
<div className="flex gap-6">
<div className="flex flex-col"><span className="text-[8px] text-slate-500 font-black uppercase">Palabras</span><span className={clsx("text-base font-mono font-black", pricing.wordCount > 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}</span></div>
<div className="flex flex-col"><span className="text-[8px] text-slate-500 font-black uppercase">Signos Especiales</span><span className={clsx("text-base font-mono font-black", pricing.specialCharCount > 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}</span></div>
<div className="col-span-4">
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Precio Sugerido ($)</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 font-black text-slate-400">$</span>
<input
type="number"
className="w-full py-2 pl-8 pr-4 border-2 border-slate-100 bg-slate-50/50 rounded-xl outline-none focus:border-blue-500 font-black text-sm text-blue-600"
placeholder="0.00"
value={formData.price}
onChange={e => setFormData({ ...formData, price: e.target.value })}
/>
</div>
<div className="text-right"><span className="text-[9px] text-slate-400 font-bold italic uppercase tracking-tighter">Vista optimizada para diario</span><div className="flex gap-1 mt-0.5 justify-end">{[1, 2, 3, 4, 5].map(i => <div key={i} className={clsx("h-0.5 w-2.5 rounded-full", formData.text.length > i * 15 ? "bg-blue-500" : "bg-slate-800")}></div>)}</div></div>
</div>
</div>
<div className="grid grid-cols-4 gap-4 bg-slate-50/50 p-4 rounded-[1.5rem] border-2 border-slate-100">
<div className="col-span-1">
<div className="flex flex-col min-h-0">
<label className={clsx("block text-[10px] font-black uppercase mb-1.5 tracking-widest transition-colors", errors.text ? "text-rose-500" : "text-slate-500")}>Cuerpo del Aviso {errors.text && "• Requerido"}</label>
<div className="relative group h-52">
<textarea ref={textInputRef} className={clsx("w-full h-full p-4 border-2 rounded-[1.2rem] resize-none outline-none font-mono text-lg tracking-tighter leading-snug transition-all duration-300", errors.text ? "border-rose-200 bg-rose-50/30" : "border-slate-100 bg-slate-50/30 group-focus-within:border-blue-400 group-focus-within:bg-white text-slate-800")} placeholder="ESCRIBA EL TEXTO AQUÍ PARA IMPRENTA..." value={formData.text} onChange={e => { setFormData({ ...formData, text: e.target.value }); setErrors({ ...errors, text: false }); }}></textarea>
</div>
<div className="flex justify-between items-center mt-2 bg-slate-900 px-5 py-2 rounded-[1rem] shadow-lg">
<div className="flex gap-6">
<div className="flex flex-col"><span className="text-[7px] text-slate-500 font-black uppercase tracking-widest">Palabras</span><span className={clsx("text-sm font-mono font-black", pricing.wordCount > 0 ? "text-blue-400" : "text-slate-700")}>{pricing.wordCount.toString().padStart(2, '0')}</span></div>
<div className="flex flex-col"><span className="text-[7px] text-slate-500 font-black uppercase tracking-widest">Signos</span><span className={clsx("text-sm font-mono font-black", pricing.specialCharCount > 0 ? "text-amber-400" : "text-slate-700")}>{pricing.specialCharCount.toString().padStart(2, '0')}</span></div>
</div>
<div className="text-right flex flex-col items-end">
<span className="text-[8px] text-slate-400 font-bold italic uppercase tracking-tighter">Vista Diario</span>
<div className="flex gap-1 mt-1 justify-end">
{[1, 2, 3, 4, 5].map(i => <div key={i} className={clsx("h-1 w-2.5 rounded-full transition-all duration-500", formData.text.length > i * 15 ? "bg-blue-500" : "bg-slate-800")}></div>)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-4 bg-white p-3 rounded-[1.5rem] border-2 border-slate-100 mt-0">
<div className="col-span-2">
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Inicio</label>
<div className="relative h-9 group">
<Calendar className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-blue-500 transition-colors" size={14} />
<input
type="date"
className="w-full h-full pl-8 pr-2 bg-slate-50 border border-slate-200 rounded-lg outline-none focus:border-blue-500 font-bold text-[11px] text-slate-600 appearance-none"
value={formData.startDate}
onChange={e => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
</div>
<div className="col-span-2">
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label>
<div className="flex items-center bg-white rounded-lg border border-slate-200 overflow-hidden h-10">
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
<button onClick={() => setFormData(f => ({ ...f, days: Math.max(1, f.days - 1) }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">-</button>
<input type="number" className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm" value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
<button onClick={() => setFormData(f => ({ ...f, days: f.days + 1 }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">+</button>
</div>
</div>
<div className="col-span-2 relative" ref={clientWrapperRef}>
<div className="col-span-5 relative" ref={clientWrapperRef}>
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
<div className="relative h-10">
<div className="relative h-9">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input type="text" className="w-full h-full pl-10 pr-4 bg-white border border-slate-200 rounded-lg outline-none focus:border-blue-500 font-extrabold text-xs tracking-tight placeholder:text-slate-400" placeholder="NOMBRE O RAZÓN SOCIAL..." value={formData.clientName} onFocus={() => setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} />
<input type="text" className="w-full h-full pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-lg outline-none focus:border-blue-500 font-extrabold text-xs tracking-tight placeholder:text-slate-400" placeholder="BUSCAR O CREAR..." value={formData.clientName} onFocus={() => setShowSuggestions(true)} onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }} />
</div>
<AnimatePresence>{showSuggestions && clientSuggestions.length > 0 && (
<motion.div initial={{ opacity: 0, y: -5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-full mb-2 left-0 right-0 bg-white border-2 border-slate-100 shadow-2xl rounded-xl overflow-hidden z-[110]">
@@ -424,9 +517,37 @@ export default function FastEntryPage() {
</motion.div>
)}</AnimatePresence>
</div>
<div className="col-span-1">
<div className="col-span-3">
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">DNI / CUIT</label>
<input type="text" className="w-full h-10 bg-white border border-slate-200 rounded-lg font-mono text-center font-black text-xs outline-none focus:border-blue-500 placeholder:text-slate-400" placeholder="S/D" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
<input type="text" className="w-full h-9 bg-slate-50 border border-slate-200 rounded-lg font-mono text-center font-black text-xs outline-none focus:border-blue-500 placeholder:text-slate-400" placeholder="S/D" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
</div>
</div>
<div className="flex gap-4 p-4 bg-slate-100/50 rounded-2xl border border-slate-200 shadow-inner">
<div
className="flex flex-1 items-center gap-3 cursor-pointer group"
onClick={() => setFormData({ ...formData, isFeatured: !formData.isFeatured })}
>
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300", formData.isFeatured ? "bg-amber-500 text-white shadow-lg scale-110" : "bg-white text-slate-400 border border-slate-200 group-hover:border-amber-300 group-hover:text-amber-500")}>
<Star className={clsx("transition-transform", formData.isFeatured && "fill-current animate-pulse")} size={18} />
</div>
<div className="flex flex-col">
<span className={clsx("text-[10px] font-black uppercase tracking-tighter leading-none transition-colors", formData.isFeatured ? "text-amber-600" : "text-slate-700")}>Aviso Destacado</span>
<span className="text-[8px] font-bold text-slate-400 uppercase mt-1">Aparece primero en la web</span>
</div>
</div>
<div className="w-px h-10 bg-slate-200"></div>
<div
className="flex flex-1 items-center gap-3 cursor-pointer group"
onClick={() => setFormData({ ...formData, allowContact: !formData.allowContact })}
>
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300", formData.allowContact ? "bg-emerald-500 text-white shadow-lg scale-110" : "bg-white text-slate-400 border border-slate-200 group-hover:border-emerald-300 group-hover:text-emerald-500")}>
<MessageSquare size={18} />
</div>
<div className="flex flex-col">
<span className={clsx("text-[10px] font-black uppercase tracking-tighter leading-none transition-colors", formData.allowContact ? "text-emerald-600" : "text-slate-700")}>Permitir Contacto</span>
<span className="text-[8px] font-bold text-slate-400 uppercase mt-1">Habilita botón de mensajes</span>
</div>
</div>
</div>
</div>
@@ -479,18 +600,57 @@ export default function FastEntryPage() {
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-4 shadow-sm flex flex-col gap-3">
<div className="flex justify-between items-center">
<h3 className="text-[9px] font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
<ImageIcon size={12} /> Multimedia
</h3>
<span className="text-[9px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full">{selectedImages.length} fotos</span>
</div>
<div className="grid grid-cols-4 gap-2">
{imagePreviews.map((url, i) => (
<div key={url} className="relative aspect-square rounded-lg overflow-hidden group shadow-sm">
<img src={url} className="w-full h-full object-cover" alt="Preview" />
<button
onClick={() => removeImage(i)}
className="absolute top-1 right-1 p-1 bg-rose-500 text-white rounded-md opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
<button
onClick={() => fileInputRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all gap-1"
>
<UploadCloud size={16} />
<span className="text-[8px] font-black uppercase">Subir</span>
</button>
<input
type="file"
ref={fileInputRef}
multiple
accept="image/*"
className="hidden"
onChange={handleImageChange}
/>
</div>
</div>
</div>
<button onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-2xl font-black shadow-xl flex flex-col items-center justify-center transition-all active:scale-95 group overflow-hidden flex-shrink-0">
<button onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700 text-white py-4 rounded-2xl font-black shadow-xl flex flex-col items-center justify-center transition-all active:scale-95 group overflow-hidden mt-auto flex-shrink-0">
<div className="flex items-center gap-3 text-lg relative z-10"><Save size={20} /> COBRAR E IMPRIMIR</div>
<span className="text-[8px] opacity-60 tracking-[0.3em] relative z-10 font-mono mt-0.5">SHORTCUT: F10</span>
<span className="text-[10px] opacity-60 tracking-[0.3em] relative z-10 font-mono mt-0.5">ATAJO: F10</span>
</button>
</div>
{showPaymentModal && (
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
)}
</motion.div>
{showPaymentModal && (
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
)}
</>
);
}

View File

@@ -12,6 +12,11 @@ dist
dist-ssr
*.local
# Carpeta de Subidas
# ---------------------
uploads/
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -27,9 +27,9 @@ public class ClientsController : ControllerBase
}
[HttpGet]
public async Task<IActionResult> GetAll()
public async Task<IActionResult> GetAll([FromQuery] string? q = null)
{
var clients = await _repo.GetAllWithStatsAsync();
var clients = await _repo.GetAllWithStatsAsync(q);
return Ok(clients);
}
@@ -64,4 +64,30 @@ public class ClientsController : ControllerBase
if (summary == null) return NotFound();
return Ok(summary);
}
[HttpPost("{id}/reset-password")]
[Microsoft.AspNetCore.Authorization.Authorize(Roles = "Admin")]
public async Task<IActionResult> ResetPassword(int id)
{
// Establecemos 1234 como clave por defecto para el blanqueo
var passwordHash = BCrypt.Net.BCrypt.HashPassword("1234");
await _repo.ResetPasswordAsync(id, passwordHash);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "RESET_CLIENT_PASSWORD",
EntityId = id,
EntityType = "User",
Details = "Contraseña de cliente restablecida a '1234' por el administrador.",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Contraseña restablecida a '1234' correctamente." });
}
}

View File

@@ -8,7 +8,6 @@ namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin")]
public class CouponsController : ControllerBase
{
private readonly ICouponRepository _repository;
@@ -21,13 +20,35 @@ public class CouponsController : ControllerBase
}
[HttpGet]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAll()
{
var coupons = await _repository.GetAllAsync();
return Ok(coupons);
}
[HttpGet("validate/{code}")]
[Authorize]
public async Task<IActionResult> GetByCode(string code)
{
var coupon = await _repository.GetByCodeAsync(code);
if (coupon == null || !coupon.IsActive)
return NotFound(new { message = "Cupón inválido o inactivo." });
if (coupon.ExpiryDate.HasValue && coupon.ExpiryDate.Value < DateTime.UtcNow)
return BadRequest(new { message = "El cupón ha expirado." });
if (coupon.MaxUsages.HasValue)
{
// Note: We might need a method to count total usages if we want strictly enforce total max usages here
// but for now, we return the coupon data so the frontend can display info.
}
return Ok(coupon);
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create(CreateCouponDto dto)
{
// Simple manual mapping
@@ -63,6 +84,7 @@ public class CouponsController : ControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(int id)
{
await _repository.DeleteAsync(id);

View File

@@ -43,25 +43,37 @@ public class ListingsController : ControllerBase
int? clientId = null;
var user = await _userRepo.GetByIdAsync(currentUserId);
// Lógica de Vinculación Usuario-Cliente
if (user != null)
// Lógica de Vinculación Distinguida (Web vs Mostrador)
if (dto.Origin == "Mostrador")
{
clientId = user.ClientId;
// Si el usuario no tiene cliente vinculado, pero envía datos de facturación (o registramos los que tiene)
if (clientId == null)
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
{
string? dniToUse = dto.ClientDni ?? user.BillingTaxId;
if (!string.IsNullOrWhiteSpace(dniToUse))
// En mostrador buscamos/creamos al cliente por su DNI, pero NO lo vinculamos al cajero
clientId = await _clientRepo.EnsureClientExistsAsync(dto.ClientName ?? "S/D", dto.ClientDni);
}
}
else
{
// Lógica para usuarios Web (Auto-servicio)
if (user != null)
{
clientId = user.ClientId;
// Si el usuario no tiene cliente vinculado, pero envía datos de facturación, lo vinculamos
if (clientId == null)
{
string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username;
clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!);
// Actualizamos el usuario con su nuevo nexo de cliente
user.ClientId = clientId;
user.BillingTaxId = dniToUse;
user.BillingName = nameToUse;
await _userRepo.UpdateAsync(user);
string? dniToUse = dto.ClientDni ?? user.BillingTaxId;
if (!string.IsNullOrWhiteSpace(dniToUse))
{
string? nameToUse = dto.ClientName ?? user.BillingName ?? user.Username;
clientId = await _clientRepo.EnsureClientExistsAsync(nameToUse!, dniToUse!);
// Actualizamos el usuario auto-servicio con su nuevo nexo de cliente
user.ClientId = clientId;
user.BillingTaxId = dniToUse;
user.BillingName = nameToUse;
await _userRepo.UpdateAsync(user);
}
}
}
}

View File

@@ -25,9 +25,9 @@ public class UsersController : ControllerBase
public async Task<IActionResult> GetAll()
{
var users = await _repository.GetAllAsync();
// Excluimos clientes para que solo aparezcan en su propio gestor
// Excluimos tanto clientes de mostrador como usuarios web para que solo aparezcan en su propio gestor
var sanitized = users
.Where(u => u.Role != "Client")
.Where(u => u.Role != "Client" && u.Role != "User")
.Select(u => new {
u.Id, u.Username, u.Role, u.Email, u.CreatedAt
});

View File

@@ -8,4 +8,8 @@ public class Client
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? TaxType { get; set; }
public string? Username { get; set; }
public bool IsActive { get; set; }
public string? Role { get; set; }
}

View File

@@ -291,6 +291,27 @@ END
";
await connection.ExecuteAsync(dataMigrationSql);
// --- NORMALIZACIÓN DE CLIENTES SIN CLAVE (Usuario corto y clave 1234) ---
var upgradeClientsSql = @"
-- Juan Perez
UPDATE Users SET Username = 'jperez', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1
WHERE Id = 1003 AND PasswordHash = 'N/A';
-- Maria Rodriguez
UPDATE Users SET Username = 'mrodriguez', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1
WHERE Id = 1004 AND PasswordHash = 'N/A';
-- Inmobiliaria City Bell
UPDATE Users SET Username = 'inmocity', PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1
WHERE Id = 1005 AND PasswordHash = 'N/A';
-- General para otros clientes migrados (si hubiera)
UPDATE Users
SET PasswordHash = '$2a$11$EaJecdmgKfGhOUEmMzT/I.hD/9WES3GehL72xXyc2stXC26ncLNsO', MustChangePassword = 1
WHERE PasswordHash = 'N/A' AND Role = 'Client';
";
await connection.ExecuteAsync(upgradeClientsSql);
// --- SEED DE DATOS (Usuario Admin) ---
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");

View File

@@ -22,7 +22,8 @@ public class ClientRepository
Id,
ISNULL(BillingName, Username) as Name,
ISNULL(BillingTaxId, '') as DniOrCuit,
Email, Phone, BillingAddress as Address
Email, Phone, BillingAddress as Address, BillingTaxType as TaxType,
Username, IsActive, Role
FROM Users
WHERE BillingName LIKE @Query OR BillingTaxId LIKE @Query OR Username LIKE @Query
ORDER BY BillingName";
@@ -54,23 +55,34 @@ public class ClientRepository
}
}
// Obtener todos con estadísticas desde Users
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
// Obtener con estadísticas desde Users (con filtro y límite para performance)
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync(string? searchTerm = null)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT
SELECT TOP 100
u.Id as id,
ISNULL(u.BillingName, u.Username) as name,
ISNULL(u.BillingTaxId, 'S/D') as dniOrCuit,
ISNULL(u.Email, 'Sin correo') as email,
ISNULL(u.Phone, 'Sin teléfono') as phone,
ISNULL(u.BillingTaxType, 'Consumidor Final') as taxType,
u.Username as username,
u.IsActive as isActive,
u.Role as role,
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = u.Id) as totalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = u.Id), 0) as totalSpent
FROM Users u
WHERE Role IN ('Client', 'User') -- Mostramos tanto clientes puros como usuarios web
ORDER BY name";
return await conn.QueryAsync(sql);
WHERE Role IN ('Client', 'User')";
if (!string.IsNullOrWhiteSpace(searchTerm))
{
sql += " AND (u.BillingName LIKE @Query OR u.BillingTaxId LIKE @Query OR u.Username LIKE @Query)";
}
sql += " ORDER BY name";
return await conn.QueryAsync(sql, new { Query = $"%{searchTerm}%" });
}
public async Task UpdateAsync(Client client)
@@ -82,7 +94,10 @@ public class ClientRepository
BillingTaxId = @DniOrCuit,
Email = @Email,
Phone = @Phone,
BillingAddress = @Address
BillingAddress = @Address,
BillingTaxType = @TaxType,
Username = @Username,
IsActive = @IsActive
WHERE Id = @Id";
await conn.ExecuteAsync(sql, client);
}
@@ -95,6 +110,8 @@ public class ClientRepository
u.Id,
ISNULL(u.BillingName, u.Username) as Name,
u.BillingTaxId as DniOrCuit, u.Email, u.Phone, u.BillingAddress as Address,
u.BillingTaxType as TaxType,
u.Username, u.IsActive, u.Role,
(SELECT COUNT(1) FROM Listings WHERE ClientId = u.Id) as TotalAds,
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = u.Id), 0) as TotalInvested,
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = u.Id) as LastAdDate,
@@ -112,4 +129,11 @@ public class ClientRepository
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
}
public async Task ResetPasswordAsync(int clientId, string passwordHash)
{
using var conn = _db.CreateConnection();
var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id";
await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId });
}
}

View File

@@ -192,11 +192,15 @@ public class ListingRepository : IListingRepository
{
using var conn = _connectionFactory.CreateConnection();
var sql = @"
DECLARE @CId INT = (SELECT ClientId FROM Users WHERE Id = @UserId);
SELECT l.*, c.Name as CategoryName,
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
FROM Listings l
JOIN Categories c ON l.CategoryId = c.Id
WHERE l.UserId = @UserId
WHERE l.UserId = @UserId
OR l.ClientId = @UserId
OR (l.ClientId = @CId AND l.ClientId IS NOT NULL)
ORDER BY l.CreatedAt DESC";
return await conn.QueryAsync<Listing>(sql, new { UserId = userId });