Feat: Cambios Varios 2
This commit is contained in:
251
frontend/counter-panel/src/pages/AdminDashboard.tsx
Normal file
251
frontend/counter-panel/src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
AreaChart, Area, PieChart, Pie, Cell
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, DollarSign, FileText, Calendar,
|
||||
Download, Clock, Zap, ArrowUpRight
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { dashboardService } from '../services/dashboardService';
|
||||
import api from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DashboardStats {
|
||||
revenueToday: number;
|
||||
adsToday: number;
|
||||
ticketAverage: number;
|
||||
paperOccupation: number;
|
||||
weeklyTrend: Array<{ day: string; amount: number }>;
|
||||
channelMix: Array<{ name: string; value: number }>;
|
||||
}
|
||||
|
||||
interface RecentListing {
|
||||
id: number;
|
||||
title: string;
|
||||
categoryName: string;
|
||||
createdAt: string;
|
||||
adFee: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#f43f5e'];
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentAds, setRecentAds] = useState<RecentListing[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const stats = await dashboardService.getStats();
|
||||
setData(stats);
|
||||
const res = await api.get('/listings');
|
||||
setRecentAds(res.data.slice(0, 5));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Formateadores locales
|
||||
const formatCurrency = (val: number) =>
|
||||
val.toLocaleString('es-AR', { minimumFractionDigits: 2 });
|
||||
|
||||
const formatLocalTime = (dateString: string) => {
|
||||
if (!dateString) return "--:--";
|
||||
let isoStr = dateString.replace(" ", "T");
|
||||
if (!isoStr.endsWith("Z")) isoStr += "Z";
|
||||
return new Date(isoStr).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: true,
|
||||
timeZone: 'America/Argentina/Buenos_Aires'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading || !data) return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-50">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] p-6 space-y-6">
|
||||
|
||||
{/* HEADER COMPACTO */}
|
||||
<div className="flex items-end justify-between">
|
||||
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Inteligencia de Negocio</span>
|
||||
<h1 className="text-2xl font-black tracking-tight text-slate-900 uppercase flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-1.5 h-6 rounded-full"></span>
|
||||
Métricas Globales
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mt-1 opacity-80">Estado operativo del diario en tiempo real</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button className="flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-[10px] font-black uppercase tracking-widest text-slate-600 shadow-sm border border-slate-200 hover:bg-slate-50 transition-all">
|
||||
<Download size={14} /> Exportar
|
||||
</button>
|
||||
<button className="flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white shadow-lg shadow-blue-500/20 hover:bg-blue-700 transition-all">
|
||||
<Calendar size={14} /> Últimos 30 días
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRID DE STATS REFINADO */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard title="Recaudación Hoy" value={`$ ${formatCurrency(data.revenueToday)}`} icon={DollarSign} color="blue" delay={0.1} />
|
||||
<StatsCard title="Avisos Publicados" value={data.adsToday} icon={FileText} color="emerald" delay={0.2} />
|
||||
<StatsCard title="Ticket Promedio" value={`$ ${formatCurrency(data.ticketAverage)}`} icon={TrendingUp} color="purple" delay={0.3} />
|
||||
<StatsCard title="Ocupación Papel" value={`${data.paperOccupation.toFixed(1)}%`} icon={Zap} color="amber" delay={0.4} />
|
||||
</div>
|
||||
|
||||
{/* SECCIÓN GRÁFICOS */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
|
||||
{/* Tendencia de Ingresos */}
|
||||
<div className="lg:col-span-2 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em]">Tendencia Semanal</h3>
|
||||
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2.5 py-1 rounded-lg border border-blue-100">+12% vs semana anterior</span>
|
||||
</div>
|
||||
<div className="h-[280px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.weeklyTrend}>
|
||||
<defs>
|
||||
<linearGradient id="colorRev" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 'bold' }} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 'bold' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)', fontSize: '12px', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="amount" stroke="#3b82f6" strokeWidth={4} fillOpacity={1} fill="url(#colorRev)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canales de Venta */}
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<h3 className="mb-8 text-xs font-black text-slate-500 uppercase tracking-[0.2em]">Mix de Canales</h3>
|
||||
<div className="h-[220px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={data.channelMix} innerRadius={65} outerRadius={85} paddingAngle={8} dataKey="value">
|
||||
{data.channelMix.map((_, idx) => <Cell key={idx} fill={COLORS[idx % COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
{data.channelMix.map((item, idx) => (
|
||||
<div key={item.name} className="flex items-center justify-between bg-slate-50 px-4 py-2 rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: COLORS[idx] }} />
|
||||
<span className="text-[10px] text-slate-600 font-black uppercase tracking-tighter">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-xs font-black text-slate-900">{item.value} ads</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLA DE ÚLTIMAS OPERACIONES (ESTILO CAJA DIARIA) */}
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white overflow-hidden shadow-xl shadow-slate-200/50 flex flex-col">
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-[0.2em]">Últimas Operaciones Recibidas</h3>
|
||||
<button className="text-[10px] font-black text-blue-600 uppercase tracking-widest hover:text-blue-700 flex items-center gap-1">
|
||||
Ver todas <ArrowUpRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50/30 text-[9px] uppercase text-slate-400 font-black tracking-[0.15em] border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="px-8 py-4">Aviso / Detalle</th>
|
||||
<th className="px-8 py-4 text-center">Horario (GMT-3)</th>
|
||||
<th className="px-8 py-4 text-center">Estado</th>
|
||||
<th className="px-8 py-4 text-right">Monto Neto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{recentAds.map((ad) => (
|
||||
<tr key={ad.id} className="hover:bg-blue-50/30 transition-colors group">
|
||||
<td className="px-8 py-4">
|
||||
<div className="font-extrabold text-slate-900 uppercase text-xs tracking-tight group-hover:text-blue-600 transition-colors">{ad.title}</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter mt-0.5">
|
||||
{ad.categoryName} • ID #{ad.id.toString().padStart(6, '0')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<div className="inline-flex items-center gap-1.5 text-slate-800 font-black bg-slate-100 px-2.5 py-1 rounded-lg text-[10px]">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{formatLocalTime(ad.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<span className={clsx(
|
||||
"inline-flex items-center rounded-md px-2.5 py-1 text-[8px] font-black uppercase tracking-tighter border",
|
||||
ad.status === 'Published'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-100'
|
||||
)}>
|
||||
{ad.status === 'Published' ? 'Cobrado' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-40 mr-1">$</span>
|
||||
{formatCurrency(ad.adFee)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// StatsCard actualizado con estética Premium
|
||||
function StatsCard({ title, value, icon: Icon, color, delay }: any) {
|
||||
const colorMap: any = {
|
||||
blue: 'text-blue-600 bg-blue-50 border-blue-100',
|
||||
emerald: 'text-emerald-600 bg-emerald-50 border-emerald-100',
|
||||
purple: 'text-purple-600 bg-purple-50 border-purple-100',
|
||||
amber: 'text-amber-600 bg-amber-50 border-amber-100',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
whileHover={{ y: -3 }}
|
||||
className="relative overflow-hidden rounded-[2rem] border border-slate-200 bg-white p-6 shadow-lg shadow-slate-200/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1.5">{title}</p>
|
||||
<h3 className="text-2xl font-black tracking-tighter text-slate-900">{value}</h3>
|
||||
</div>
|
||||
<div className={clsx("flex h-11 w-11 items-center justify-center rounded-2xl border shadow-inner", colorMap[color])}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -right-4 h-16 w-16 rounded-full bg-slate-50 opacity-50" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
312
frontend/counter-panel/src/pages/AdvancedAnalytics.tsx
Normal file
312
frontend/counter-panel/src/pages/AdvancedAnalytics.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import { dashboardService } from '../services/dashboardService';
|
||||
import type {
|
||||
AdvancedAnalyticsData,
|
||||
PaymentMethodStat,
|
||||
CategoryPerformanceStat
|
||||
} from '../types/Analytics';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, AreaChart, Area
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, DollarSign,
|
||||
Clock, ArrowUpRight, ArrowDownRight, Award, PieChart as PieChartIcon
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Colores para gráficos con estética premium
|
||||
const COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981'];
|
||||
|
||||
export default function AdvancedAnalytics() {
|
||||
const [data, setData] = useState<AdvancedAnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState('month');
|
||||
|
||||
const loadAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const end = new Date().toISOString();
|
||||
const start = new Date();
|
||||
if (timeRange === 'week') start.setDate(start.getDate() - 7);
|
||||
else if (timeRange === 'month') start.setMonth(start.getMonth() - 1);
|
||||
else if (timeRange === 'year') start.setFullYear(start.getFullYear() - 1);
|
||||
|
||||
const res = await dashboardService.getAdvancedAnalytics(start.toISOString(), end);
|
||||
setData(res);
|
||||
} catch (error) {
|
||||
console.error('Error al cargar analítica:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, [loadAnalytics]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="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="text-slate-500 font-bold uppercase tracking-widest text-xs text-center">Sincronizando con el servidor central...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-700">
|
||||
{/* Encabezado con selector de rango temporal */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-4xl font-black text-slate-900 tracking-tighter uppercase flex items-center gap-3">
|
||||
<span className="bg-blue-600 w-2 h-8 rounded-full"></span>
|
||||
BI & Analítica Avanzada
|
||||
</h1>
|
||||
<p className="text-slate-400 font-bold uppercase tracking-widest text-[10px] mt-1">Inteligencia de negocios y reportes gerenciales</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-1 rounded-2xl shadow-xl border border-slate-100 flex gap-1">
|
||||
{['week', 'month', 'year'].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setTimeRange(r)}
|
||||
className={`px-6 py-2 rounded-xl text-xs font-black uppercase tracking-widest transition-all ${timeRange === r ? 'bg-slate-900 text-white shadow-lg' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{r === 'week' ? '7 Días' : r === 'month' ? '30 Días' : 'Año'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tarjetas de KPIs Principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<KPICard
|
||||
title="Recaudación Neto"
|
||||
value={`$${data.totalRevenue?.toLocaleString() || 0}`}
|
||||
growth={data.revenueGrowth}
|
||||
icon={<DollarSign className="text-blue-600" />}
|
||||
color="blue"
|
||||
/>
|
||||
<KPICard
|
||||
title="Avisos Publicados"
|
||||
value={data.totalAds}
|
||||
growth={data.adsGrowth}
|
||||
icon={<Award className="text-emerald-600" />}
|
||||
color="emerald"
|
||||
/>
|
||||
<KPICard
|
||||
title="Promedio por Aviso"
|
||||
value={`$${((data.totalRevenue || 0) / (data.totalAds || 1)).toLocaleString()}`}
|
||||
icon={<TrendingUp className="text-indigo-600" />}
|
||||
color="indigo"
|
||||
simple
|
||||
/>
|
||||
<KPICard
|
||||
title="Crecimiento vs Ant."
|
||||
value={`${data.revenueGrowth?.toFixed(1) || 0}%`}
|
||||
icon={<PieChartIcon className="text-amber-600" />}
|
||||
color="amber"
|
||||
simple
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Gráfico de Línea: Tendencia Temporal */}
|
||||
<div className="lg:col-span-8 bg-white p-8 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-8">Tendencia de Ingresos Diarios</h3>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data.dailyTrends}>
|
||||
<defs>
|
||||
<linearGradient id="colorAmountArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} dy={10} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#64748b' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)' }}
|
||||
labelStyle={{ fontWeight: 'bold', color: '#1e293b' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="amount" stroke="#3b82f6" strokeWidth={4} fillOpacity={1} fill="url(#colorAmountArea)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfico de Torta: Canales de Cobro */}
|
||||
<div className="lg:col-span-4 bg-white p-8 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-8">Manejo de Tesorería</h3>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.paymentsDistribution}
|
||||
innerRadius={70}
|
||||
outerRadius={100}
|
||||
paddingAngle={8}
|
||||
dataKey="total"
|
||||
nameKey="method"
|
||||
>
|
||||
{data.paymentsDistribution?.map((_: PaymentMethodStat, index: number) => (
|
||||
<Cell key={`cell-analytics-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 mt-4 px-4 overflow-y-auto max-h-[100px]">
|
||||
{data.paymentsDistribution?.map((p: PaymentMethodStat, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: COLORS[idx % COLORS.length] }}></div>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase">{p.method}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-slate-900">${p.total?.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentro del return de AdvancedAnalytics.tsx */}
|
||||
<div className="bg-white p-8 rounded-[3rem] shadow-xl border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">Eficiencia de Canales</h3>
|
||||
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg">Real-time</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<ChannelProgress
|
||||
label="Mostrador (Venta Presencial)"
|
||||
value={data.sourceMix?.mostradorPercent || 0}
|
||||
count={data.sourceMix?.mostradorCount || 0}
|
||||
color="bg-slate-900"
|
||||
/>
|
||||
<ChannelProgress
|
||||
label="Web (Wizard Digital)"
|
||||
value={data.sourceMix?.webPercent || 0}
|
||||
count={data.sourceMix?.webCount || 0}
|
||||
color="bg-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Gráfico de Barras: Horas Pico */}
|
||||
<div className="bg-slate-900 p-10 rounded-[3rem] text-white shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full -mr-32 -mt-32 blur-[80px]"></div>
|
||||
<h3 className="text-lg font-black uppercase tracking-tight mb-10 flex items-center gap-3 relative z-10">
|
||||
<Clock size={20} className="text-blue-400" />
|
||||
Frecuencia de Operatividad (Horas Pico)
|
||||
</h3>
|
||||
<div className="h-[280px] relative z-10">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.hourlyActivity}>
|
||||
<XAxis dataKey="hour" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 10 }} />
|
||||
<Tooltip cursor={{ fill: '#ffffff10' }} contentStyle={{ color: '#000', borderRadius: '12px', border: 'none' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listado de Rubros: Participación en Ingresos */}
|
||||
<div className="bg-white p-10 rounded-[3rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight mb-10">Ranking de Rubros por Ingresos</h3>
|
||||
<div className="space-y-6">
|
||||
{data.categoryPerformance?.slice(0, 5).map((cp: CategoryPerformanceStat, idx: number) => (
|
||||
<div key={idx} className="group cursor-default">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-[10px] font-black text-slate-900 uppercase tracking-widest">{cp.categoryName}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-black text-blue-600 block leading-none">${cp.revenue?.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-slate-50 rounded-full overflow-hidden border border-slate-100">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${cp.share || 0}%` }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-indigo-600 rounded-full"
|
||||
></motion.div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 opacity-60">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase">{cp.adsCount} avisos</span>
|
||||
<span className="text-[9px] font-black text-indigo-500 uppercase">{cp.share?.toFixed(1)}% del total</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KPICardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
growth?: number;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
simple?: boolean;
|
||||
}
|
||||
|
||||
function ChannelProgress({ label, value, count, color }: any) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-end text-[10px] font-black uppercase mb-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="text-slate-900 text-xs">{count} avisos</span>
|
||||
</div>
|
||||
<span className="text-blue-600 text-lg tracking-tighter">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${value}%` }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className={clsx("h-full shadow-lg", color)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subcomponente reutilizable para tarjetas de métricas (KPIs)
|
||||
function KPICard({ title, value, growth, icon, color, simple = false }: KPICardProps) {
|
||||
const isPositive = (growth ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ y: -5, scale: 1.02 }}
|
||||
className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 relative overflow-hidden"
|
||||
>
|
||||
<div className={`absolute top-0 right-0 w-20 h-20 bg-${color}-500/5 rounded-full -mr-10 -mt-10`}></div>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
{!simple && growth !== undefined && (
|
||||
<div className={`flex items-center gap-1 text-[9px] font-black uppercase px-2 py-1 rounded-lg ${isPositive ? 'bg-emerald-50 text-emerald-600' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{isPositive ? <ArrowUpRight size={12} /> : <ArrowDownRight size={12} />}
|
||||
{Math.abs(growth).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 leading-none">{title}</h4>
|
||||
<div className="text-3xl font-black text-slate-900 tracking-tighter leading-none">{value}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,295 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cashRegisterService } from '../services/cashRegisterService';
|
||||
import { Printer, Download, Clock, CheckCircle, RefreshCw } from 'lucide-react';
|
||||
import type { GlobalReport, ReportItem } from '../types/Report';
|
||||
import {
|
||||
Printer,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
Search,
|
||||
Filter,
|
||||
ArrowRightLeft
|
||||
} from 'lucide-react';
|
||||
import CashClosingModal from '../components/CashClosingModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
|
||||
export default function CashRegisterPage() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const { showToast } = useToast();
|
||||
const [data, setData] = useState<GlobalReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showClosingModal, setShowClosingModal] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async (isManual = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await cashRegisterService.getDailyStatus();
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isManual) showToast('Datos de caja sincronizados correctamente.', 'success');
|
||||
} catch {
|
||||
showToast('Error al conectar con el servidor de tesorería.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
}, [loadData]);
|
||||
|
||||
const handleCerrarCaja = async () => {
|
||||
if (!confirm("¿Desea finalizar el turno y descargar el comprobante de cierre?")) return;
|
||||
try {
|
||||
await cashRegisterService.downloadClosurePdf();
|
||||
alert("Cierre generado con éxito. Entregue el reporte junto con el efectivo.");
|
||||
} catch (e) {
|
||||
alert("Error al generar el PDF");
|
||||
}
|
||||
const handleCerrarCaja = () => {
|
||||
setShowClosingModal(true);
|
||||
};
|
||||
|
||||
if (loading && !data) return <div className="p-10 text-center text-gray-500">Sincronizando caja...</div>;
|
||||
const handleClosingComplete = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const formatCurrency = (val: number) =>
|
||||
val.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// SOLUCIÓN AL HORARIO: Forzar interpretación UTC
|
||||
const formatLocalTime = (dateString: string) => {
|
||||
if (!dateString) return "--:--";
|
||||
|
||||
// 1. Convertimos el formato de SQL (espacio) a ISO (T)
|
||||
let isoStr = dateString.replace(" ", "T");
|
||||
|
||||
// 2. Si no tiene la 'Z' al final, se la agregamos para decirle al navegador: "Esto es UTC"
|
||||
if (!isoStr.endsWith("Z")) {
|
||||
isoStr += "Z";
|
||||
}
|
||||
|
||||
const date = new Date(isoStr);
|
||||
|
||||
return date.toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: 'America/Argentina/Buenos_Aires' // Forzamos la zona de Argentina
|
||||
});
|
||||
};
|
||||
|
||||
const filteredItems = data?.items.filter((item: ReportItem) =>
|
||||
item.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) || [];
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-slate-500">
|
||||
<RefreshCw size={32} className="animate-spin text-blue-600" />
|
||||
<span className="font-black tracking-[0.2em] text-[10px] uppercase">Sincronizando flujos...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full flex flex-col gap-6 bg-gray-100 h-full overflow-y-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-slate-800 tracking-tight">MI CAJA DIARIA</h2>
|
||||
<p className="text-gray-500 text-sm flex items-center gap-1">
|
||||
<Clock size={14} /> Turno actual: {new Date().toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={loadData} className="p-2 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
||||
<div className="p-5 w-full flex flex-col gap-5 bg-slate-50/50 h-full overflow-hidden">
|
||||
|
||||
{/* Header Section Refinado */}
|
||||
<div className="flex justify-between items-start">
|
||||
<motion.div initial={{ opacity: 0, x: -15 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Control de Tesorería</span>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-2">
|
||||
<span className="bg-blue-600 w-1.5 h-5 rounded-full"></span>
|
||||
Caja Diaria
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-white border border-slate-200 rounded-lg text-[9px] font-black text-slate-500 shadow-sm uppercase">
|
||||
<Clock size={10} className="text-blue-500" />
|
||||
TURNO: {new Date().toLocaleDateString('es-AR', { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, x: 15 }} animate={{ opacity: 1, x: 0 }} className="flex gap-3">
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
className="p-2 bg-white border border-slate-200 text-slate-500 hover:text-blue-600 hover:border-blue-200 rounded-xl transition-all shadow-sm active:scale-95"
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCerrarCaja}
|
||||
className="bg-gray-900 text-white px-6 py-2 rounded-lg font-bold flex items-center gap-2 hover:bg-black transition-all shadow-lg active:scale-95"
|
||||
className="bg-slate-900 text-white px-5 py-2 rounded-xl font-black text-[10px] flex items-center gap-2 hover:bg-black transition-all shadow-lg shadow-slate-200 active:scale-95 group tracking-widest"
|
||||
>
|
||||
<Printer size={20} /> FINALIZAR Y CERRAR (F4)
|
||||
<Printer size={14} className="text-blue-400" />
|
||||
CERRAR CAJA (F4)
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* KPIs DE CAJA */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-green-500 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Efectivo a Rendir</div>
|
||||
<div className="text-4xl font-black text-slate-800 mt-1">$ {data?.totalRevenue?.toLocaleString('es-AR', { minimumFractionDigits: 2 })}</div>
|
||||
{/* KPI Section Refinado (Más compacto) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<KPICard
|
||||
icon={<Banknote size={18} />}
|
||||
label="Recaudación Turno"
|
||||
value={`$ ${formatCurrency(data?.totalRevenue || 0)}`}
|
||||
badge="EN EFECTIVO"
|
||||
color="emerald"
|
||||
/>
|
||||
<KPICard
|
||||
icon={<CheckCircle size={18} />}
|
||||
label="Avisos Procesados"
|
||||
value={data?.totalAds || 0}
|
||||
badge="OPERACIONES"
|
||||
color="blue"
|
||||
/>
|
||||
<motion.div
|
||||
whileHover={{ y: -3 }}
|
||||
className="bg-slate-900 p-4 rounded-[1.5rem] shadow-xl flex flex-col justify-between text-white overflow-hidden relative border-b-4 border-blue-600"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-blue-600/10 rounded-full -mr-8 -mt-8 blur-2xl"></div>
|
||||
<div className="flex justify-between items-start mb-2 relative z-10">
|
||||
<div className="p-2 bg-white/5 rounded-xl text-blue-400"><Wallet size={16} /></div>
|
||||
<span className="text-[8px] font-black text-blue-400 bg-blue-500/10 border border-blue-500/20 px-2 py-0.5 rounded-md uppercase tracking-widest">PRÓXIMO CIERRE</span>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-full text-green-600">
|
||||
<Download size={32} />
|
||||
<div className="relative z-10">
|
||||
<div className="text-slate-500 text-[8px] font-black uppercase tracking-widest mb-0.5">Arqueo Estimado</div>
|
||||
<div className="text-2xl font-mono font-black text-white leading-none">
|
||||
$ {formatCurrency(data?.totalRevenue || 0)}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 opacity-20">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="h-0.5 flex-1 bg-white rounded-full"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm border-b-4 border-blue-500 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-bold uppercase tracking-widest">Avisos Procesados</div>
|
||||
<div className="text-4xl font-black text-slate-800 mt-1">{data?.totalAds} <span className="text-lg text-gray-300">Clasificados</span></div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-full text-blue-600">
|
||||
<CheckCircle size={32} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* TABLA DE MOVIMIENTOS */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden flex-1 flex flex-col">
|
||||
<div className="p-4 bg-gray-50 border-b font-bold text-slate-600 text-sm uppercase tracking-wider">
|
||||
Detalle de transacciones del turno
|
||||
{/* Table Section (Contraste aumentado) */}
|
||||
<div className="flex-1 bg-white rounded-[1.8rem] shadow-xl shadow-slate-200/50 border border-slate-200 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative max-w-xs w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar movimiento..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-4 focus:ring-blue-500/5 outline-none transition-all placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-white border border-slate-200 rounded-xl text-[9px] font-black text-slate-600 hover:border-slate-400 transition-all shadow-sm uppercase tracking-tighter">
|
||||
<Filter size={12} /> Filtrar Pagos
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[9px] font-black uppercase tracking-widest text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Turno Activo
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-gray-50 text-gray-400 text-[10px] uppercase font-bold sticky top-0 z-10">
|
||||
<thead className="bg-slate-50 text-slate-600 text-[9px] uppercase font-black tracking-[0.15em] sticky top-0 z-10 backdrop-blur-md border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="p-4 border-b">ID</th>
|
||||
<th className="p-4 border-b">Hora</th>
|
||||
<th className="p-4 border-b">Aviso / Título</th>
|
||||
<th className="p-4 border-b">Rubro</th>
|
||||
<th className="p-4 border-b text-right">Monto</th>
|
||||
<th className="px-6 py-4">ID Operación</th>
|
||||
<th className="px-6 py-4 text-center">Horario (GMT-3)</th>
|
||||
<th className="px-6 py-4">Detalle del Aviso</th>
|
||||
<th className="px-6 py-4">Rubro</th>
|
||||
<th className="px-6 py-4 text-right">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-sm">
|
||||
{data?.items.map((item: any) => (
|
||||
<tr key={item.id} className="hover:bg-blue-50/50 transition-colors">
|
||||
<td className="p-4 font-mono text-gray-400">#{item.id}</td>
|
||||
<td className="p-4 text-gray-600">{new Date(item.date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||
<td className="p-4 font-bold text-slate-700">{item.title}</td>
|
||||
<td className="p-4">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded text-[10px] font-bold text-gray-500 uppercase">{item.category}</span>
|
||||
</td>
|
||||
<td className="p-4 text-right font-black text-slate-900">$ {item.amount.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-20 text-center text-gray-400 italic">No se han registrado cobros en este turno todavía.</td>
|
||||
</tr>
|
||||
)}
|
||||
<tbody className="divide-y divide-slate-50 text-xs">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredItems.map((item: ReportItem, idx: number) => (
|
||||
<motion.tr
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.02 }}
|
||||
className="hover:bg-blue-50/40 transition-all group"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono text-slate-500 font-bold">#{item.id.toString().padStart(6, '0')}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="inline-flex items-center gap-1.5 text-slate-800 font-black bg-slate-100 px-2.5 py-1 rounded-lg">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{formatLocalTime(item.date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-extrabold text-slate-900 tracking-tight group-hover:text-blue-600 transition-colors uppercase line-clamp-1">{item.title}</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold tracking-tighter opacity-80 italic">{item.clientName || 'Consumidor Final'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="bg-slate-100 px-2.5 py-1 rounded-md text-[8px] font-black text-slate-700 uppercase tracking-tighter border border-slate-200">
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-40 mr-1">$</span>
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer de la tabla con el total */}
|
||||
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
|
||||
<span className="text-xs font-bold uppercase opacity-60">Total en Caja</span>
|
||||
<span className="text-xl font-mono font-bold text-green-400">$ {data?.totalRevenue?.toLocaleString()}</span>
|
||||
{/* Footer Dark Bar (Más compacta y alto contraste) */}
|
||||
<div className="px-6 py-4 bg-slate-900 border-t border-slate-800 flex justify-between items-center shadow-[0_-10px_30px_rgba(0,0,0,0.15)] flex-shrink-0">
|
||||
<div className="flex items-center gap-8">
|
||||
<SummaryItem icon={<Banknote size={14} />} label="Efectivo" value={formatCurrency(data?.totalCash || 0)} color="text-emerald-400" />
|
||||
<SummaryItem icon={<CreditCard size={14} />} label="Tarjetas" value={formatCurrency((data?.totalDebit || 0) + (data?.totalCredit || 0))} color="text-blue-400" />
|
||||
<SummaryItem icon={<ArrowRightLeft size={14} />} label="Transf." value={formatCurrency(data?.totalTransfer || 0)} color="text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[8px] font-black text-blue-500 uppercase tracking-[0.2em] mb-0.5">Cierre Parcial</span>
|
||||
<div className="text-xl font-mono font-black text-white flex items-baseline gap-1.5">
|
||||
<span className="text-[9px] font-sans font-bold text-white/30 uppercase tracking-widest">TOTAL</span>
|
||||
<span className="text-green-400">$ {formatCurrency(data?.totalRevenue || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showClosingModal && (
|
||||
<CashClosingModal onClose={() => setShowClosingModal(false)} onComplete={handleClosingComplete} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPICard({ icon, label, value, badge, color }: any) {
|
||||
const colorStyles: any = {
|
||||
emerald: "bg-emerald-500/10 text-emerald-600",
|
||||
blue: "bg-blue-500/10 text-blue-600"
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div whileHover={{ y: -3 }} className="bg-white p-4 rounded-[1.5rem] shadow-lg border border-slate-100 flex flex-col justify-between">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className={`p-2 rounded-xl ${colorStyles[color]}`}>{icon}</div>
|
||||
<span className={`text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest ${colorStyles[color]}`}>{badge}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-600 text-[8px] font-black uppercase tracking-widest mb-0.5">{label}</div>
|
||||
<div className="text-2xl font-mono font-black text-slate-950 leading-none">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ icon, label, value, color }: any) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 bg-white/5 rounded-lg ${color}`}>{icon}</div>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-[7px] font-black text-slate-500 uppercase tracking-tighter">{label}</span>
|
||||
<span className="text-xs font-mono font-bold text-white tracking-tighter">$ {value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
import {
|
||||
Printer, Save,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
Type, Search, ChevronDown, Bold, Square as FrameIcon
|
||||
Type, Search, ChevronDown, Bold, Square as FrameIcon,
|
||||
ArrowUpRight,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import { useCashSession } from '../hooks/useCashSession';
|
||||
import CashOpeningModal from '../components/CashOpeningModal';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Interfaces
|
||||
interface Operation { id: number; name: string; }
|
||||
@@ -18,9 +26,22 @@ interface PricingResult {
|
||||
specialCharCount: number; details: string; appliedPromotion: string;
|
||||
}
|
||||
|
||||
const escapeHTML = (str: string) => {
|
||||
return str.replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[m] || m));
|
||||
};
|
||||
|
||||
export default function FastEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const { session, loading: sessionLoading, refreshSession } = useCashSession();
|
||||
|
||||
const [categorySearch, setCategorySearch] = useState("");
|
||||
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
||||
@@ -45,6 +66,8 @@ export default function FastEntryPage() {
|
||||
const clientWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const textInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const debouncedText = useDebounce(formData.text, 500);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||
|
||||
const filteredCategories = flatCategories.filter(cat =>
|
||||
cat.path.toLowerCase().includes(categorySearch.toLowerCase()) ||
|
||||
@@ -53,7 +76,17 @@ export default function FastEntryPage() {
|
||||
|
||||
const selectedCategoryName = flatCategories.find(c => c.id === parseInt(formData.categoryId))?.name;
|
||||
|
||||
const printCourtesyTicket = (data: any, priceInfo: PricingResult) => {
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, boolean> = {
|
||||
categoryId: !formData.categoryId,
|
||||
operationId: !formData.operationId,
|
||||
text: !formData.text || formData.text.trim().length === 0,
|
||||
};
|
||||
setErrors(newErrors);
|
||||
return !Object.values(newErrors).some(v => v);
|
||||
}, [formData]);
|
||||
|
||||
const printCourtesyTicket = (data: typeof formData, priceInfo: PricingResult) => {
|
||||
const printWindow = window.open('', '_blank', 'width=300,height=600');
|
||||
if (!printWindow) return;
|
||||
|
||||
@@ -66,12 +99,12 @@ export default function FastEntryPage() {
|
||||
<p>${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>CLIENTE:</b> ${data.clientName || 'Consumidor Final'}<br/>
|
||||
<b>RUBRO:</b> ${selectedCategoryName}<br/>
|
||||
<b>CLIENTE:</b> ${escapeHTML(data.clientName || 'Consumidor Final')}<br/>
|
||||
<b>RUBRO:</b> ${escapeHTML(selectedCategoryName || '')}<br/>
|
||||
<b>DÍAS:</b> ${data.days}<br/>
|
||||
</div>
|
||||
<div style="background: #f0f0f0; padding: 8px; margin: 10px 0; border: 1px solid #000;">
|
||||
${data.text.toUpperCase()}
|
||||
${escapeHTML(data.text).toUpperCase()}
|
||||
</div>
|
||||
<div style="text-align: right; border-top: 1px dashed #000; padding-top: 5px;">
|
||||
<b style="font-size: 16px;">TOTAL: $${priceInfo.totalPrice.toLocaleString()}</b>
|
||||
@@ -85,6 +118,78 @@ export default function FastEntryPage() {
|
||||
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
|
||||
};
|
||||
|
||||
const printPaymentReceipt = (data: typeof formData, priceInfo: PricingResult, payments: Payment[]) => {
|
||||
const printWindow = window.open('', '_blank', 'width=350,height=700');
|
||||
if (!printWindow) return;
|
||||
|
||||
const username = localStorage.getItem('username') || 'Cajero';
|
||||
const now = new Date();
|
||||
const totalWithSurcharges = payments.reduce((sum, p) => sum + p.amount + p.surcharge, 0);
|
||||
|
||||
const paymentRows = payments.map(p => {
|
||||
const paymentMethodMap: Record<string, string> = {
|
||||
'Cash': 'Efectivo',
|
||||
'Debit': 'Débito',
|
||||
'Credit': p.cardPlan || 'Crédito',
|
||||
'Transfer': 'Transferencia'
|
||||
};
|
||||
const methodName = paymentMethodMap[p.paymentMethod] || p.paymentMethod;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 5px 0; border-bottom: 1px solid #ddd;">${methodName}</td>
|
||||
<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd;">$${p.amount.toLocaleString()}</td>
|
||||
${p.surcharge > 0 ? `<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd; color: #f59e0b;">+$${p.surcharge.toLocaleString()}</td>` : '<td></td>'}
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<head><style>@media print { body { margin: 0; } }</style></head>
|
||||
<body style="font-family: 'Courier New', monospace; width: 320px; padding: 15px; font-size: 11px; background: white;">
|
||||
<div style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 10px;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: bold;">DIARIO EL DÍA</h1>
|
||||
<p style="margin: 2px 0; font-size: 10px;">Comprobante de Pago</p>
|
||||
<p style="margin: 2px 0; font-size: 10px; font-weight: bold;">N° ${Math.floor(Math.random() * 100000).toString().padStart(8, '0')}</p>
|
||||
<p style="margin: 5px 0; font-size: 9px;">${now.toLocaleDateString('es-AR')} - ${now.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p style="margin: 3px 0;"><b>CLIENTE:</b> ${escapeHTML(data.clientName || 'Consumidor Final')}</p>
|
||||
${data.clientDni ? `<p style="margin: 3px 0;"><b>DNI/CUIT:</b> ${escapeHTML(data.clientDni)}</p>` : ''}
|
||||
<p style="margin: 3px 0;"><b>ATENDIDO POR:</b> ${escapeHTML(username)}</p>
|
||||
</div>
|
||||
<div style="border-top: 1px dashed #000; border-bottom: 1px dashed #000; padding: 8px 0; margin-bottom: 10px;">
|
||||
<p style="margin: 3px 0; font-weight: bold;">DETALLE DEL SERVICIO:</p>
|
||||
<p style="margin: 3px 0;">Rubro: ${escapeHTML(selectedCategoryName || '')}</p>
|
||||
<p style="margin: 3px 0;">Duración: ${data.days} día${data.days > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<table style="width: 100%; font-size: 10px;">
|
||||
<tr><td>Tarifa Base</td><td style="text-align: right;">$${priceInfo.baseCost.toLocaleString()}</td></tr>
|
||||
${priceInfo.extraCost > 0 ? `<tr><td>Recargo texto</td><td style="text-align: right;">+$${priceInfo.extraCost.toLocaleString()}</td></tr>` : ''}
|
||||
<tr style="border-top: 2px solid #000; font-weight: bold; font-size: 12px;">
|
||||
<td style="padding: 8px 0;">SUBTOTAL</td><td style="text-align: right; padding: 8px 0;">$${priceInfo.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="border-top: 2px solid #000; padding-top: 10px; margin-bottom: 10px;">
|
||||
<p style="margin: 5px 0; font-weight: bold; text-align: center;">MEDIOS DE PAGO</p>
|
||||
<table style="width: 100%; font-size: 10px;">${paymentRows}</table>
|
||||
</div>
|
||||
<div style="background: #000; color: #fff; padding: 12px; text-align: center; border-radius: 5px;">
|
||||
<p style="margin: 0; font-size: 10px;">TOTAL A PAGAR</p>
|
||||
<p style="margin: 5px 0; font-size: 24px; font-weight: bold;">$${totalWithSurcharges.toLocaleString('es-AR', { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -96,16 +201,18 @@ export default function FastEntryPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validate()) return;
|
||||
setShowPaymentModal(true);
|
||||
}, [validate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F10') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
if (e.key === 'F10') { e.preventDefault(); handleSubmit(); }
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [formData, pricing, options]);
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -142,13 +249,7 @@ export default function FastEntryPage() {
|
||||
}
|
||||
}, [debouncedClientSearch, showSuggestions]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.categoryId || !formData.operationId || !formData.text) {
|
||||
alert("⚠️ Error: Complete Rubro, Operación y Texto.");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`¿Confirmar cobro de $${pricing.totalPrice.toLocaleString()}?`)) return;
|
||||
|
||||
const handlePaymentConfirm = async (payments: Payment[]) => {
|
||||
try {
|
||||
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await api.post('/listings', {
|
||||
@@ -156,21 +257,30 @@ export default function FastEntryPage() {
|
||||
operationId: parseInt(formData.operationId),
|
||||
title: formData.text.substring(0, 40) + '...',
|
||||
description: formData.text,
|
||||
price: 0, adFee: pricing.totalPrice,
|
||||
price: 0,
|
||||
adFee: pricing.totalPrice,
|
||||
status: 'Published',
|
||||
printText: formData.text,
|
||||
printStartDate: tomorrow.toISOString(),
|
||||
printDaysCount: formData.days,
|
||||
isBold: options.isBold, isFrame: options.isFrame,
|
||||
printFontSize: options.fontSize, printAlignment: options.alignment,
|
||||
clientName: formData.clientName, clientDni: formData.clientDni
|
||||
isBold: options.isBold,
|
||||
isFrame: options.isFrame,
|
||||
printFontSize: options.fontSize,
|
||||
printAlignment: options.alignment,
|
||||
clientName: formData.clientName,
|
||||
clientDni: formData.clientDni,
|
||||
payments
|
||||
});
|
||||
|
||||
printCourtesyTicket(formData, pricing);
|
||||
setTimeout(() => { printPaymentReceipt(formData, pricing, payments); }, 500);
|
||||
setFormData({ ...formData, text: '', clientName: '', clientDni: '' });
|
||||
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
||||
alert('✅ Aviso procesado correctamente.');
|
||||
} catch (err) { alert('❌ Error al procesar el cobro.'); }
|
||||
setShowPaymentModal(false);
|
||||
setErrors({});
|
||||
showToast('Aviso procesado correctamente.', 'success');
|
||||
} catch (err) {
|
||||
showToast('Error al procesar el cobro.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectClient = (client: Client) => {
|
||||
@@ -178,249 +288,209 @@ export default function FastEntryPage() {
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-slate-50">
|
||||
<RefreshCw className="animate-spin text-blue-600" size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-4 flex gap-4 bg-gray-100 overflow-hidden max-h-screen">
|
||||
<>
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* PANEL IZQUIERDO: FORMULARIO */}
|
||||
<div className="flex-[7] bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col min-h-0">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-gray-100 pb-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-800 tracking-tight uppercase">Nueva Publicación</h2>
|
||||
<p className="text-[10px] text-slate-400 font-mono">ID TERMINAL: T-01 | CAJA: 01</p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.99 }}
|
||||
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>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-3">
|
||||
<span className="bg-blue-600 w-1.5 h-6 rounded-full"></span>
|
||||
Nueva Publicación
|
||||
</h2>
|
||||
<p className="text-[9px] text-slate-500 font-bold tracking-[0.2em] mt-0.5 uppercase opacity-80">Caja 01 - Recepción de Avisos</p>
|
||||
</div>
|
||||
<div className="bg-slate-100 p-2 rounded-xl text-slate-400"><Printer size={18} /></div>
|
||||
</div>
|
||||
<Printer size={24} className="text-slate-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="relative" ref={catWrapperRef}>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Rubro</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full p-3 border rounded-xl bg-gray-50 flex justify-between items-center cursor-pointer transition-all",
|
||||
isCatDropdownOpen ? "border-blue-500 ring-4 ring-blue-500/10" : "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
onClick={() => setIsCatDropdownOpen(!isCatDropdownOpen)}
|
||||
>
|
||||
<span className={clsx("font-bold text-sm", !formData.categoryId && "text-gray-400")}>
|
||||
{selectedCategoryName || "-- Seleccionar Rubro --"}
|
||||
</span>
|
||||
<ChevronDown size={18} className={clsx("text-gray-400 transition-transform", isCatDropdownOpen && "rotate-180")} />
|
||||
<div className="flex flex-col gap-6 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")}>
|
||||
Rubro Seleccionado {errors.categoryId && "• Requerido"}
|
||||
</label>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full p-3.5 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)}
|
||||
>
|
||||
<span className={clsx("font-extrabold text-sm tracking-tight", !formData.categoryId ? "text-slate-400" : "text-slate-800")}>
|
||||
{selectedCategoryName || "BUSCAR RUBRO..."}
|
||||
</span>
|
||||
<ChevronDown size={18} className={clsx("transition-transform", isCatDropdownOpen ? "rotate-180 text-blue-500" : "text-slate-400")} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isCatDropdownOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-full left-0 right-0 bg-white border-2 border-slate-100 shadow-2xl rounded-2xl mt-2 z-[100] flex flex-col max-h-[350px] overflow-hidden">
|
||||
<div className="p-3 bg-slate-50 border-b flex items-center gap-2">
|
||||
<Search size={16} className="text-slate-400" />
|
||||
<input autoFocus type="text" placeholder="Filtrar por nombre o código..." className="bg-transparent w-full outline-none text-sm font-bold text-slate-700 placeholder:text-slate-400" value={categorySearch} onChange={(e) => setCategorySearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-2 px-1 custom-scrollbar">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat.id} className={clsx("mx-1 px-4 py-2.5 text-xs cursor-pointer rounded-lg transition-all mb-1 flex items-center gap-3", !cat.isSelectable ? "text-slate-300 italic opacity-60" : "hover:bg-blue-50 text-slate-600 font-bold", parseInt(formData.categoryId) === cat.id && "bg-blue-600 text-white shadow-lg pl-6")} style={{ marginLeft: `${(cat.level * 12) + 4}px` }} onClick={() => { if (cat.isSelectable) { setFormData({ ...formData, categoryId: cat.id.toString() }); setIsCatDropdownOpen(false); setErrors({ ...errors, categoryId: false }); } }}>
|
||||
<span className={clsx("w-1 h-1 rounded-full", parseInt(formData.categoryId) === cat.id ? "bg-white" : "bg-slate-300")}></span>
|
||||
{cat.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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 }); }}>
|
||||
<option value="">ELIJA OPERACIÓN...</option>
|
||||
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCatDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl mt-2 z-[100] flex flex-col max-h-[300px] overflow-hidden">
|
||||
<div className="p-3 bg-gray-50 border-b flex items-center gap-2">
|
||||
<Search size={16} className="text-gray-400" />
|
||||
<input autoFocus type="text" placeholder="Filtrar rubros..." className="bg-transparent w-full outline-none text-sm font-medium"
|
||||
value={categorySearch} onChange={(e) => setCategorySearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 py-2">
|
||||
{filteredCategories.map(cat => (
|
||||
<div key={cat.id} className={clsx("px-4 py-2 text-sm cursor-pointer border-l-4 transition-all",
|
||||
!cat.isSelectable ? "text-gray-400 font-bold bg-gray-50/50 italic pointer-events-none" : "hover:bg-blue-50 border-transparent hover:border-blue-600",
|
||||
parseInt(formData.categoryId) === cat.id && "bg-blue-100 border-blue-600 font-bold"
|
||||
)}
|
||||
style={{ paddingLeft: `${(cat.level * 16) + 16}px` }}
|
||||
onClick={() => { setFormData({ ...formData, categoryId: cat.id.toString() }); setIsCatDropdownOpen(false); }}
|
||||
>
|
||||
{cat.name}
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<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}>
|
||||
<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">
|
||||
<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); }} />
|
||||
</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]">
|
||||
{clientSuggestions.map(client => (
|
||||
<div key={client.id} className="p-3 hover:bg-blue-50 cursor-pointer border-b border-slate-50 flex justify-between items-center group transition-colors" onClick={() => handleSelectClient(client)}>
|
||||
<div><div className="font-extrabold text-slate-800 text-[11px] uppercase group-hover:text-blue-600">{client.name}</div><div className="text-[9px] text-slate-500 font-mono mt-0.5">{client.dniOrCuit}</div></div>
|
||||
<ArrowUpRight size={12} className="text-slate-300 group-hover:text-blue-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}</AnimatePresence>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<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 })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Operación</label>
|
||||
<select className="w-full p-3 border border-gray-200 rounded-xl bg-gray-50 outline-none focus:border-blue-500 font-bold text-sm"
|
||||
value={formData.operationId} onChange={e => setFormData({ ...formData, operationId: e.target.value })}>
|
||||
<option value="">-- Operación --</option>
|
||||
{operations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
{/* PANEL DERECHO */}
|
||||
<div className="flex-[3] flex flex-col gap-4 min-h-0 overflow-hidden">
|
||||
<div className="bg-slate-900 text-white rounded-[2rem] p-5 shadow-xl border-b-4 border-blue-600 flex-shrink-0">
|
||||
<div className="text-[9px] text-blue-400 uppercase tracking-widest mb-0.5 font-black">Total a Cobrar</div>
|
||||
<div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div>
|
||||
{pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>}
|
||||
{pricing.discount > 0 && <div className="mt-2 p-2 bg-green-500/10 rounded-lg text-green-400 flex flex-col border border-green-500/20"><div className="flex justify-between"><span>Descuento</span><span>-${pricing.discount.toLocaleString()}</span></div></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 tracking-wider">Cuerpo del Aviso (Texto para Imprenta)</label>
|
||||
<textarea
|
||||
ref={textInputRef}
|
||||
className="flex-1 w-full p-5 border border-gray-300 rounded-2xl resize-none focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none font-mono text-xl text-slate-700 leading-relaxed shadow-inner bg-gray-50/30"
|
||||
placeholder="ESCRIBA EL TEXTO AQUÍ..."
|
||||
value={formData.text}
|
||||
onChange={e => setFormData({ ...formData, text: e.target.value })}
|
||||
></textarea>
|
||||
<div className="flex justify-between items-center mt-3 bg-slate-50 p-2 rounded-lg border border-slate-100">
|
||||
<div className="flex gap-4 text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||
<span className={pricing.wordCount > 0 ? "text-blue-600" : ""}>Palabras: {pricing.wordCount}</span>
|
||||
{pricing.specialCharCount > 0 && <span className="text-orange-600">Signos: {pricing.specialCharCount}</span>}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-300 font-bold italic uppercase">Uso de mayúsculas recomendado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Días</label>
|
||||
<input type="number" className="w-full p-2.5 border border-gray-200 rounded-lg font-black text-center text-blue-600 outline-none"
|
||||
value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
|
||||
</div>
|
||||
<div className="col-span-2 relative" ref={clientWrapperRef}>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">Cliente / Razón Social</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 text-gray-300" size={16} />
|
||||
<input type="text" className="w-full pl-9 p-2.5 border border-gray-200 rounded-lg outline-none focus:border-blue-500 font-bold text-sm"
|
||||
placeholder="Buscar o crear..." value={formData.clientName}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onChange={e => { setFormData({ ...formData, clientName: e.target.value }); setShowSuggestions(true); }}
|
||||
/>
|
||||
</div>
|
||||
{showSuggestions && clientSuggestions.length > 0 && (
|
||||
<div className="absolute bottom-full mb-2 left-0 right-0 bg-white border border-gray-200 shadow-2xl rounded-xl overflow-hidden z-[110]">
|
||||
{clientSuggestions.map(client => (
|
||||
<div key={client.id} className="p-3 hover:bg-blue-50 cursor-pointer border-b border-gray-50 flex justify-between items-center transition-colors"
|
||||
onClick={() => handleSelectClient(client)}>
|
||||
<div>
|
||||
<div className="font-bold text-slate-800 text-xs">{client.name}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">{client.dniOrCuit}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col gap-4 pr-1 custom-scrollbar">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 p-2.5 flex flex-col gap-2 shadow-sm flex-shrink-0">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
{['left', 'center', 'right', 'justify'].map(align => (
|
||||
<button key={align} onClick={() => setOptions({ ...options, alignment: align })} className={clsx("p-1.5 rounded-md hover:bg-white transition-all", options.alignment === align && "bg-white text-blue-600 shadow-sm")}>
|
||||
{align === 'left' && <AlignLeft size={14} />}{align === 'center' && <AlignCenter size={14} />}{align === 'right' && <AlignRight size={14} />}{align === 'justify' && <AlignJustify size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase mb-1">DNI / CUIT</label>
|
||||
<input type="text" className="w-full p-2.5 border border-gray-200 rounded-lg font-mono text-center font-bold text-sm"
|
||||
placeholder="Documento" value={formData.clientDni} onChange={e => setFormData({ ...formData, clientDni: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL DERECHO: TOTALES Y VISTA PREVIA */}
|
||||
<div className="flex-[3] flex flex-col gap-4 min-h-0 overflow-hidden">
|
||||
|
||||
{/* TOTALES (FIJO ARRIBA) */}
|
||||
<div className="bg-slate-900 text-white rounded-3xl p-6 shadow-xl border-b-4 border-blue-600 flex-shrink-0">
|
||||
<div className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-black">Total a Cobrar</div>
|
||||
<div className="text-5xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-xl mt-1.5 opacity-50">$</span>
|
||||
{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2 text-[11px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-400 italic">
|
||||
<span>Tarifa Base</span>
|
||||
<span className="text-white">${pricing.baseCost.toLocaleString()}</span>
|
||||
</div>
|
||||
{pricing.extraCost > 0 && (
|
||||
<div className="flex justify-between text-orange-400">
|
||||
<span>Recargos por texto</span>
|
||||
<span>+${pricing.extraCost.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{pricing.surcharges > 0 && (
|
||||
<div className="flex justify-between text-blue-400">
|
||||
<span>Estilos visuales</span>
|
||||
<span>+${pricing.surcharges.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{pricing.discount > 0 && (
|
||||
<div className="mt-2 p-2 bg-green-500/10 rounded-lg text-green-400 flex flex-col border border-green-500/20 animate-pulse">
|
||||
<div className="flex justify-between">
|
||||
<span>Descuento Aplicado</span>
|
||||
<span>-${pricing.discount.toLocaleString()}</span>
|
||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||
{['small', 'normal', 'large'].map(size => (
|
||||
<button key={size} onClick={() => setOptions({ ...options, fontSize: size })} className={clsx("p-1.5 rounded-md hover:bg-white transition-all flex items-end", options.fontSize === size && "bg-white text-blue-600 shadow-sm")}>
|
||||
<Type size={size === 'small' ? 10 : size === 'normal' ? 14 : 18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] opacity-70 italic">{pricing.appliedPromotion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
<button onClick={() => setOptions({ ...options, isBold: !options.isBold })} className={clsx("p-2 rounded-lg border text-[9px] font-black transition-all flex items-center justify-center gap-2", options.isBold ? "bg-blue-600 border-blue-600 text-white" : "bg-slate-50 border-slate-200 text-slate-400 hover:bg-slate-100")}><Bold size={12} /> NEGRITA</button>
|
||||
<button onClick={() => setOptions({ ...options, isFrame: !options.isFrame })} className={clsx("p-2 rounded-lg border text-[9px] font-black transition-all flex items-center justify-center gap-2", options.isFrame ? "bg-slate-800 border-slate-800 text-white" : "bg-slate-50 border-slate-200 text-slate-400 hover:bg-slate-100")}><FrameIcon size={12} /> RECUADRO</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#fffef5] border border-yellow-200 rounded-2xl p-4 shadow-sm min-h-[140px] h-auto flex flex-col relative group flex-shrink-0">
|
||||
<div className="absolute top-0 left-0 w-full h-0.5 bg-yellow-400 opacity-20"></div>
|
||||
<h3 className="text-[8px] font-black text-yellow-700/60 uppercase mb-3 flex items-center gap-1.5 tracking-widest"><Printer size={10} /> Previsualización Real</h3>
|
||||
<div className="p-1">
|
||||
<div className={clsx("w-full leading-tight whitespace-pre-wrap break-words transition-all duration-300", options.isBold ? "font-bold text-slate-900" : "font-medium text-slate-700", options.isFrame ? "border-2 border-slate-900 p-3 bg-white shadow-sm" : "border-none", options.fontSize === 'small' ? 'text-[11px]' : options.fontSize === 'large' ? 'text-base' : 'text-xs', options.alignment === 'center' ? 'text-center' : options.alignment === 'right' ? 'text-right' : options.alignment === 'justify' ? 'text-justify' : 'text-left')}>
|
||||
{formData.text || "(Aviso vacío)"}
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CONTENEDOR CENTRAL SCROLLABLE (Toolbar + Preview) */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex flex-col gap-4 pr-1 custom-scrollbar">
|
||||
|
||||
{/* TOOLBAR ESTILOS */}
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-2 flex flex-col gap-2 shadow-sm flex-shrink-0">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
|
||||
{['left', 'center', 'right', 'justify'].map(align => (
|
||||
<button key={align} onClick={() => setOptions({ ...options, alignment: align })}
|
||||
className={clsx("p-1.5 rounded-md hover:bg-white transition-all shadow-sm", options.alignment === align && "bg-white text-blue-600")}>
|
||||
{align === 'left' && <AlignLeft size={16} />}
|
||||
{align === 'center' && <AlignCenter size={16} />}
|
||||
{align === 'right' && <AlignRight size={16} />}
|
||||
{align === 'justify' && <AlignJustify size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1 gap-1">
|
||||
{['small', 'normal', 'large'].map(size => (
|
||||
<button key={size} onClick={() => setOptions({ ...options, fontSize: size })}
|
||||
className={clsx("p-1.5 rounded-md hover:bg-white transition-all flex items-end", options.fontSize === size && "bg-white shadow-sm text-blue-600")}>
|
||||
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions({ ...options, isBold: !options.isBold })}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
|
||||
options.isBold ? "bg-blue-600 border-blue-600 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<Bold size={14} /> NEGRITA
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions({ ...options, isFrame: !options.isFrame })}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg border text-[10px] font-black transition-all flex items-center justify-center gap-2",
|
||||
options.isFrame ? "bg-slate-800 border-slate-800 text-white shadow-inner" : "bg-gray-50 border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<FrameIcon size={14} /> RECUADRO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VISTA PREVIA (DINÁMICA AL ALTO DEL TEXTO) */}
|
||||
<div className="bg-[#fffef5] border border-yellow-200 rounded-2xl p-5 shadow-sm min-h-[180px] h-auto flex flex-col relative group flex-shrink-0">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-yellow-400 opacity-20"></div>
|
||||
<h3 className="text-[9px] font-black text-yellow-700 uppercase mb-4 flex items-center gap-1.5 opacity-40 tracking-widest">
|
||||
<Printer size={12} /> Previsualización Real
|
||||
</h3>
|
||||
<div className="p-2">
|
||||
<div className={clsx(
|
||||
"w-full leading-tight whitespace-pre-wrap break-words transition-all duration-300",
|
||||
options.isBold ? "font-bold text-gray-900" : "font-medium text-gray-700",
|
||||
options.isFrame ? "border-2 border-gray-900 p-4 bg-white shadow-md" : "border-none",
|
||||
options.fontSize === 'small' ? 'text-xs' : options.fontSize === 'large' ? 'text-lg' : 'text-sm',
|
||||
options.alignment === 'center' ? 'text-center' : options.alignment === 'right' ? 'text-right' : options.alignment === 'justify' ? 'text-justify' : 'text-left'
|
||||
)}>
|
||||
{formData.text || "(Aviso vacío)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACCIÓN PRINCIPAL (FIJO ABAJO) */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white py-5 rounded-2xl font-black shadow-2xl flex flex-col items-center justify-center gap-0.5 transition-all active:scale-95 group relative overflow-hidden flex-shrink-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
||||
<div className="flex items-center gap-3 text-xl relative z-10">
|
||||
<Save size={24} /> COBRAR E IMPRIMIR
|
||||
</div>
|
||||
<span className="text-[9px] opacity-60 tracking-[0.3em] relative z-10 font-mono">SHORTCUT: F10</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showPaymentModal && (
|
||||
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
524
frontend/counter-panel/src/pages/HistoryPage.tsx
Normal file
524
frontend/counter-panel/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import {
|
||||
Search, User as UserIcon, ChevronRight,
|
||||
X, FileText, Banknote, CreditCard,
|
||||
Clock, Filter, Printer,
|
||||
MessageSquare, ShieldAlert, CheckCircle2,
|
||||
ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import clsx from 'clsx';
|
||||
import ClaimModal from '../components/ClaimModal';
|
||||
import ResolveClaimModal from '../components/ResolveClaimModal';
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [cajeros, setCajeros] = useState<any[]>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
const [showClaimModal, setShowClaimModal] = useState(false);
|
||||
const [claims, setClaims] = useState<any[]>([]);
|
||||
const [resolvingClaim, setResolvingClaim] = useState<any>(null);
|
||||
const [activeDetailTab, setActiveDetailTab] = useState<'info' | 'claims'>('info');
|
||||
|
||||
// Filtros
|
||||
const [filters, setFilters] = useState({
|
||||
from: new Date().toISOString().split('T')[0],
|
||||
to: new Date().toISOString().split('T')[0],
|
||||
userId: '',
|
||||
query: '',
|
||||
source: 'All' // 'All' | 'Web' | 'Mostrador'
|
||||
});
|
||||
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const res = await api.get('/reports/cajeros');
|
||||
setCajeros(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/reports/cashier-transactions', {
|
||||
params: {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
userId: filters.userId || null
|
||||
}
|
||||
});
|
||||
setItems(response.data.items || []);
|
||||
} catch (error) {
|
||||
console.error("Error al cargar historial:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const loadItemClaims = async (listingId: number) => {
|
||||
try {
|
||||
const res = await api.get(`/claims/listing/${listingId}`);
|
||||
setClaims(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleOpenDetail = async (id: number) => {
|
||||
setActiveDetailTab('info');
|
||||
try {
|
||||
const res = await api.get(`/listings/${id}`);
|
||||
setSelectedItem(res.data);
|
||||
loadItemClaims(id);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const formatLocalTime = (dateString: string) => {
|
||||
let isoStr = dateString.replace(" ", "T");
|
||||
if (!isoStr.endsWith("Z")) isoStr += "Z";
|
||||
return new Date(isoStr).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit', minute: '2-digit', hour12: true,
|
||||
timeZone: 'America/Argentina/Buenos_Aires'
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHTML = (str: string) => {
|
||||
return str?.replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[m] || m)) || '';
|
||||
};
|
||||
|
||||
const handlePrintDuplicate = () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const { listing, payments } = selectedItem;
|
||||
const printWindow = window.open('', '_blank', 'width=350,height=700');
|
||||
if (!printWindow) return;
|
||||
|
||||
const now = new Date();
|
||||
const totalWithSurcharges = payments.reduce((sum: number, p: any) => sum + p.amount + p.surcharge, 0);
|
||||
|
||||
const paymentRows = payments.map((p: any) => {
|
||||
const methodName = p.paymentMethod === 'Credit' ? p.cardPlan : p.paymentMethod;
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 5px 0; border-bottom: 1px solid #ddd;">${methodName}</td>
|
||||
<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd;">$${p.amount.toLocaleString()}</td>
|
||||
${p.surcharge > 0 ? `<td style="padding: 5px 0; text-align: right; border-bottom: 1px solid #ddd; color: #f59e0b;">+$${p.surcharge.toLocaleString()}</td>` : '<td></td>'}
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<body style="font-family: 'Courier New', monospace; width: 300px; padding: 10px; font-size: 11px;">
|
||||
<div style="text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; margin-bottom: 10px;">
|
||||
<h1 style="margin: 0; font-size: 18px;">DIARIO EL DÍA</h1>
|
||||
<p style="margin: 2px 0; font-weight: bold; background: #000; color: #fff; display: inline-block; padding: 2px 10px;">*** DUPLICADO ***</p>
|
||||
<p style="margin: 5px 0; font-size: 9px;">Original: ${new Date(listing.createdAt).toLocaleString('es-AR')}</p>
|
||||
<p style="margin: 2px 0; font-size: 9px;">Re-impresión: ${now.toLocaleString('es-AR')}</p>
|
||||
</div>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<b>AVISO ID:</b> #${listing.id.toString().padStart(6, '0')}<br/>
|
||||
<b>RUBRO:</b> ${escapeHTML(listing.categoryName)}<br/>
|
||||
<b>ESTADO:</b> ${listing.status}<br/>
|
||||
</div>
|
||||
<div style="border-top: 1px dashed #000; border-bottom: 1px dashed #000; padding: 8px 0; margin-bottom: 10px;">
|
||||
<b>CUERPO DEL AVISO:</b><br/>
|
||||
<p style="text-transform: uppercase; line-height: 1.4;">${escapeHTML(listing.description)}</p>
|
||||
</div>
|
||||
<table style="width: 100%; font-size: 10px; margin-bottom: 10px;">
|
||||
${paymentRows}
|
||||
</table>
|
||||
<div style="background: #000; color: #fff; padding: 10px; text-align: center; font-size: 18px; font-weight: bold;">
|
||||
TOTAL: $${totalWithSurcharges.toLocaleString()}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
setTimeout(() => { printWindow.print(); printWindow.close(); }, 250);
|
||||
showToast("Duplicado enviado a la cola de impresión", "success");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 flex flex-col gap-6 h-full bg-[#f8fafc] overflow-hidden">
|
||||
|
||||
{/* FILTROS */}
|
||||
<div className="bg-white rounded-[2rem] p-6 shadow-xl border border-slate-200 flex-shrink-0">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<span className="text-[9px] font-black text-blue-600 uppercase tracking-[0.3em] mb-0.5 block">Auditoría & Consultas</span>
|
||||
<h2 className="text-xl font-black text-slate-900 tracking-tight uppercase flex items-center gap-2">
|
||||
<span className="bg-slate-900 w-1.5 h-5 rounded-full"></span>
|
||||
Historial de Operaciones
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadHistory}
|
||||
className="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-black text-[10px] uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Clock className="animate-spin" size={14} /> : <Filter size={14} />}
|
||||
Actualizar Vista
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Desde</label>
|
||||
<input type="date" value={filters.from} onChange={e => setFilters({ ...filters, from: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Hasta</label>
|
||||
<input type="date" value={filters.to} onChange={e => setFilters({ ...filters, to: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Origen del Aviso</label>
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||||
{['All', 'Mostrador', 'Web'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilters({ ...filters, source: s })}
|
||||
className={clsx(
|
||||
"flex-1 py-2 text-[9px] font-black uppercase rounded-lg transition-all",
|
||||
filters.source === s ? "bg-white text-blue-600 shadow-sm" : "text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
{s === 'All' ? 'Todos' : s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Cajero Responsable</label>
|
||||
<select
|
||||
value={filters.userId}
|
||||
onChange={e => setFilters({ ...filters, userId: e.target.value })}
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="">TODOS LOS CAJEROS</option>
|
||||
{cajeros.map((u) => (
|
||||
<option key={u.id || u.Id} value={u.id || u.Id}>
|
||||
{(u.username || u.Username || 'Sin Nombre').toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-slate-500 uppercase ml-1">Búsqueda Directa</label>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input type="text" placeholder="Título o aviso..." value={filters.query} onChange={e => setFilters({ ...filters, query: e.target.value })} className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl p-3 pl-10 text-xs font-bold text-slate-700 outline-none focus:border-blue-500 transition-all placeholder:text-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABLA */}
|
||||
<div className="flex-1 bg-white rounded-[2rem] shadow-xl border border-slate-200 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="overflow-y-auto flex-1 custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-500 text-[9px] uppercase font-black tracking-[0.15em] sticky top-0 z-10 border-b border-slate-100 backdrop-blur-md">
|
||||
<tr>
|
||||
<th className="px-8 py-4">Operación / Fecha</th>
|
||||
<th className="px-8 py-4">Detalle del Aviso</th>
|
||||
<th className="px-8 py-4 text-center">Cajero</th>
|
||||
<th className="px-8 py-4 text-right">Monto Total</th>
|
||||
<th className="px-8 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items
|
||||
.filter(i => (filters.source === 'All' || i.source === filters.source))
|
||||
.filter(i => i.title.toLowerCase().includes(filters.query.toLowerCase()))
|
||||
.map((item) => (
|
||||
<motion.tr
|
||||
key={item.id}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
className="hover:bg-blue-50/40 transition-all group"
|
||||
>
|
||||
<td className="px-8 py-4">
|
||||
{/* Badge de Origen */}
|
||||
<div className={clsx(
|
||||
"inline-flex px-2 py-0.5 rounded text-[8px] font-black uppercase mb-2",
|
||||
item.source === 'Web' ? "bg-blue-600 text-white shadow-lg shadow-blue-200" : "bg-slate-200 text-slate-600"
|
||||
)}>
|
||||
{item.source}
|
||||
</div>
|
||||
<div className="font-mono text-slate-400 font-bold text-[10px]">#{item.id.toString().padStart(6, '0')}</div>
|
||||
<div className="text-[10px] font-black text-slate-700 mt-1 flex items-center gap-1.5">
|
||||
<Clock size={12} className="text-blue-500" />
|
||||
{new Date(item.date).toLocaleDateString('es-AR')} - {formatLocalTime(item.date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-extrabold text-slate-900 uppercase text-xs tracking-tight line-clamp-1 group-hover:text-blue-600 transition-colors">{item.title}</span>
|
||||
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter mt-0.5">{item.category}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-center">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-100 rounded-lg text-[9px] font-black text-slate-600 uppercase border border-slate-200">
|
||||
<UserIcon size={10} className="text-slate-400" /> {item.cashier || 'SISTEMA'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right font-black text-slate-950 text-sm">
|
||||
<span className="text-[10px] opacity-30 mr-1">$</span>
|
||||
{item.amount.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="px-8 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleOpenDetail(item.id)}
|
||||
className="p-2.5 bg-slate-50 text-slate-400 hover:bg-blue-600 hover:text-white rounded-xl transition-all shadow-sm active:scale-90"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL LATERAL */}
|
||||
<AnimatePresence>
|
||||
{selectedItem && (
|
||||
<div className="fixed inset-0 bg-slate-950/40 backdrop-blur-sm z-[200] flex justify-end">
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
className="bg-white w-full max-w-xl h-full shadow-2xl flex flex-col border-l border-slate-200"
|
||||
>
|
||||
{/* HEADER DEL PANEL */}
|
||||
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/30">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em]">Auditoría de Aviso</span>
|
||||
<h3 className="text-2xl font-black text-slate-900 tracking-tighter uppercase">#{selectedItem.listing.id.toString().padStart(6, '0')}</h3>
|
||||
</div>
|
||||
<button onClick={() => setSelectedItem(null)} className="p-3 hover:bg-slate-100 rounded-2xl transition-colors text-slate-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* BARRA DE NAVEGACIÓN DE PESTAÑAS (TABS) */}
|
||||
<div className="flex bg-white border-b border-slate-100 px-8 gap-8">
|
||||
<button
|
||||
onClick={() => setActiveDetailTab('info')}
|
||||
className={clsx(
|
||||
"py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b-2",
|
||||
activeDetailTab === 'info' ? "border-blue-600 text-blue-600" : "border-transparent text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
Ficha Técnica
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveDetailTab('claims')}
|
||||
className={clsx(
|
||||
"py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b-2 flex items-center gap-2",
|
||||
activeDetailTab === 'claims' ? "border-rose-500 text-rose-500" : "border-transparent text-slate-400 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
Incidencias
|
||||
{claims.length > 0 && (
|
||||
<span className={clsx(
|
||||
"px-1.5 py-0.5 rounded-md text-[9px]",
|
||||
activeDetailTab === 'claims' ? "bg-rose-500 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{claims.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CUERPO DEL PANEL CON SCROLL INDEPENDIENTE */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* PESTAÑA 1: INFORMACIÓN GENERAL */}
|
||||
{activeDetailTab === 'info' && (
|
||||
<motion.div
|
||||
key="info-tab" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-10"
|
||||
>
|
||||
{/* Status & Total */}
|
||||
<div className="bg-blue-600 text-white p-6 rounded-[2rem] shadow-xl shadow-blue-200 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20"><CheckCircle2 size={80} /></div>
|
||||
<div className="relative z-10">
|
||||
<span className="text-[10px] font-black uppercase opacity-60">Recaudación Total</span>
|
||||
<div className="text-4xl font-mono font-black">$ {selectedItem.listing.adFee?.toLocaleString('es-AR')}</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-[10px] font-bold uppercase bg-white/10 w-fit px-3 py-1 rounded-full border border-white/20">
|
||||
<ShieldCheck size={14} /> {selectedItem.listing.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Texto para Imprenta */}
|
||||
<section>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<FileText size={14} className="text-slate-500" /> Cuerpo de Publicación
|
||||
</h4>
|
||||
<div className="p-6 bg-slate-900 rounded-[1.5rem] text-slate-300 font-mono text-sm leading-relaxed uppercase">
|
||||
{selectedItem.listing.description}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Desglose de Pagos */}
|
||||
<section>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<Banknote size={14} className="text-emerald-500" /> Medios de Pago
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedItem.payments?.map((p: any, pIdx: number) => (
|
||||
<div key={pIdx} className="bg-slate-50 p-4 rounded-2xl border border-slate-100 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm text-slate-400">
|
||||
{p.paymentMethod === 'Cash' ? <Banknote size={18} /> : <CreditCard size={18} />}
|
||||
</div>
|
||||
<span className="font-black text-slate-700 uppercase text-[11px]">{p.paymentMethod} {p.cardPlan || ''}</span>
|
||||
</div>
|
||||
<span className="font-mono font-black text-slate-900">$ {p.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Datos Técnicos */}
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<InfoBox label="Rubro" value={selectedItem.listing.categoryName} />
|
||||
<InfoBox label="Duración" value={`${selectedItem.listing.printDaysCount} DÍAS`} />
|
||||
<InfoBox label="Fecha Op." value={new Date(selectedItem.listing.createdAt).toLocaleDateString()} />
|
||||
<InfoBox label="Responsable" value={selectedItem.listing.userId ? `ID #${selectedItem.listing.userId}` : 'Web'} />
|
||||
</section>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* PESTAÑA 2: INCIDENCIAS Y RECLAMOS */}
|
||||
{activeDetailTab === 'claims' && (
|
||||
<motion.div
|
||||
key="claims-tab" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[10px] font-black text-rose-500 uppercase tracking-widest flex items-center gap-2">
|
||||
<ShieldAlert size={14} /> Historial de Reclamos
|
||||
</h4>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase">Total: {claims.length} eventos</span>
|
||||
</div>
|
||||
|
||||
{claims.length === 0 ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-100 text-slate-300">
|
||||
<ShieldAlert size={40} className="mb-4 opacity-20" />
|
||||
<p className="font-black text-[10px] uppercase tracking-widest">Sin incidencias registradas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{claims.map((c: any) => (
|
||||
<div key={c.id} className={clsx(
|
||||
"p-6 rounded-[2rem] border-2 transition-all relative",
|
||||
c.status === 'Open' ? "bg-rose-50 border-rose-100 shadow-lg shadow-rose-100/50" : "bg-emerald-50 border-emerald-100"
|
||||
)}>
|
||||
{/* ... (Contenido del card de reclamo, igual que antes) ... */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className={clsx(
|
||||
"px-2.5 py-1 rounded-lg text-[9px] font-black uppercase tracking-tighter",
|
||||
c.status === 'Open' ? "bg-rose-500 text-white" : "bg-emerald-500 text-white"
|
||||
)}>
|
||||
{c.status === 'Open' ? 'Pendiente de Resolución' : 'Incidencia Resuelta'}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">{new Date(c.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p className="text-sm font-black text-slate-800 uppercase mb-2">{c.claimType}</p>
|
||||
<p className="text-xs text-slate-600 leading-relaxed bg-white/50 p-3 rounded-xl border border-black/5 italic mb-4">"{c.description}"</p>
|
||||
|
||||
{c.status === 'Open' ? (
|
||||
<button
|
||||
onClick={() => setResolvingClaim(c)}
|
||||
className="w-full py-3 bg-white border border-rose-200 text-rose-500 rounded-xl text-[10px] font-black uppercase hover:bg-rose-500 hover:text-white transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Brindar Solución
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 mt-4 pt-4 border-t border-black/5">
|
||||
{c.originalValues && (
|
||||
<div className="p-3 bg-slate-200/50 rounded-xl">
|
||||
<p className="text-[8px] font-black text-slate-400 uppercase mb-1">Snapshot Pre-ajuste:</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono leading-tight">{c.originalValues}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-white rounded-xl border border-emerald-200">
|
||||
<p className="text-[9px] font-black text-emerald-700 uppercase mb-1">Solución aplicada:</p>
|
||||
<p className="text-[11px] text-emerald-800 font-bold italic">"{c.solutionDescription}"</p>
|
||||
<p className="text-[8px] mt-2 text-emerald-600/50 font-black uppercase">Por: {c.resolvedByUsername} • {new Date(c.resolvedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* FOOTER GLOBAL (SIEMPRE VISIBLE) */}
|
||||
<div className="p-8 bg-slate-50 border-t border-slate-100 flex gap-4">
|
||||
<button onClick={handlePrintDuplicate} className="flex-1 bg-white border-2 border-slate-200 text-slate-600 font-black text-[10px] uppercase py-4 rounded-2xl hover:bg-slate-100 transition-all flex items-center justify-center gap-2">
|
||||
<Printer size={16} /> Duplicado Ticket
|
||||
</button>
|
||||
<button onClick={() => setShowClaimModal(true)} className="flex-1 bg-slate-900 text-white font-black text-[10px] uppercase py-4 rounded-2xl hover:bg-black transition-all flex items-center justify-center gap-2 shadow-lg">
|
||||
<MessageSquare size={16} className="text-blue-400" /> Abrir Reclamo
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* MODAL DE RECLAMO */}
|
||||
<AnimatePresence>
|
||||
{showClaimModal && selectedItem && (
|
||||
<ClaimModal
|
||||
listingId={selectedItem.listing.id}
|
||||
onClose={() => setShowClaimModal(false)}
|
||||
onSuccess={() => loadItemClaims(selectedItem.listing.id)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* MODAL DE RESOLUCION DE RECLAMO */}
|
||||
<AnimatePresence>
|
||||
{resolvingClaim && selectedItem && (
|
||||
<ResolveClaimModal
|
||||
claim={resolvingClaim}
|
||||
listing={selectedItem.listing}
|
||||
onClose={() => setResolvingClaim(null)}
|
||||
onSuccess={() => {
|
||||
loadItemClaims(selectedItem.listing.id);
|
||||
loadHistory();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ label, value, color = "text-slate-800" }: any) {
|
||||
return (
|
||||
<div className="p-3.5 bg-white border border-slate-100 rounded-xl shadow-sm">
|
||||
<span className="text-[8px] font-black text-slate-400 uppercase block mb-0.5 tracking-widest">{label}</span>
|
||||
<span className={clsx("text-[11px] font-black uppercase truncate block", color)}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,12 @@ export default function LoginPage() {
|
||||
try {
|
||||
const res = await api.post('/auth/login', { username, password });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem('user', username); // Guardar usuario para mostrar en header
|
||||
|
||||
// CAMBIO AQUÍ: Guardar como objeto stringificado
|
||||
localStorage.setItem('user', JSON.stringify({ username }));
|
||||
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
alert('Credenciales inválidas');
|
||||
}
|
||||
};
|
||||
|
||||
154
frontend/counter-panel/src/pages/TreasuryPage.tsx
Normal file
154
frontend/counter-panel/src/pages/TreasuryPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import {
|
||||
ShieldCheck, AlertCircle, CheckCircle2,
|
||||
User as UserIcon, Clock
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function TreasuryPage() {
|
||||
const { showToast } = useToast();
|
||||
const [pending, setPending] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSession, setSelectedSession] = useState<any>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/cashsessions/pending');
|
||||
setPending(res.data);
|
||||
} catch (e) {
|
||||
showToast("Error al cargar sesiones pendientes", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadPending(); }, []);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedSession) return;
|
||||
try {
|
||||
await api.post(`/cashsessions/${selectedSession.id}/validate`, JSON.stringify(notes), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
showToast("Caja liquidada y archivada", "success");
|
||||
setSelectedSession(null);
|
||||
setNotes('');
|
||||
loadPending();
|
||||
} catch (e) {
|
||||
showToast("Error al validar", "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-20 text-center uppercase font-black text-xs text-slate-400 animate-pulse">Cargando Tesorería...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 flex flex-col gap-8 bg-[#f8fafc] h-full">
|
||||
<header>
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em] mb-1 block">Administración Central</span>
|
||||
<h2 className="text-3xl font-black text-slate-900 tracking-tight uppercase">Validación de Cajas</h2>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
|
||||
{/* LISTADO DE CAJAS PENDIENTES */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Sesiones esperando cierre definitivo</h4>
|
||||
{pending.length === 0 ? (
|
||||
<div className="bg-white p-20 rounded-[2.5rem] border-2 border-dashed border-slate-200 text-center opacity-40">
|
||||
<CheckCircle2 size={48} className="mx-auto mb-4 text-emerald-500" />
|
||||
<p className="font-black text-xs uppercase tracking-widest">No hay cajas pendientes de validación</p>
|
||||
</div>
|
||||
) : (
|
||||
pending.map(s => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
whileHover={{ x: 5 }}
|
||||
onClick={() => setSelectedSession(s)}
|
||||
className={clsx(
|
||||
"p-6 bg-white rounded-[2rem] border-2 transition-all cursor-pointer flex justify-between items-center group",
|
||||
selectedSession?.id === s.id ? "border-blue-600 shadow-xl shadow-blue-100" : "border-slate-100 hover:border-blue-200 shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center text-slate-400 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<UserIcon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-sm">{s.username}</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1.5 mt-1">
|
||||
<Clock size={12} /> Cerrada: {new Date(s.closingDate).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-mono font-black text-slate-900">$ {(s.declaredCash + s.declaredCards + s.declaredTransfers).toLocaleString()}</p>
|
||||
<span className="text-[9px] font-black text-blue-600 bg-blue-50 px-2 py-1 rounded-md mt-1 inline-block">PENDIENTE LIQUIDAR</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PANEL DE ACCIÓN (DERECHA) */}
|
||||
<div className="lg:col-span-5">
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedSession ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }}
|
||||
className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-2xl relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5"><ShieldCheck size={120} /></div>
|
||||
<h3 className="text-xl font-black uppercase mb-8 flex items-center gap-3">
|
||||
<AlertCircle className="text-blue-400" /> Detalle de Liquidación
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 relative z-10">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<TreasuryStat label="Efectivo Declarado" value={selectedSession.declaredCash} />
|
||||
<TreasuryStat label="Diferencia" value={selectedSession.totalDifference} color={selectedSession.totalDifference >= 0 ? "text-emerald-400" : "text-rose-400"} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-3xl p-6 border border-white/10 space-y-4">
|
||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest block">Observaciones de Tesorería</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Indique si el dinero coincide con el sobre entregado..."
|
||||
className="w-full bg-transparent border-none outline-none text-sm font-medium placeholder:opacity-20 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="w-full py-5 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-500/20 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<CheckCircle2 size={18} /> Validar y Archivar Caja
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="p-10 text-center text-slate-300 border-2 border-dashed border-slate-200 rounded-[2.5rem]">
|
||||
<p className="text-xs font-black uppercase tracking-widest">Seleccione una caja para auditar</p>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreasuryStat({ label, value, color = "text-white" }: any) {
|
||||
return (
|
||||
<div className="bg-white/5 p-4 rounded-2xl border border-white/10">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase block mb-1">{label}</span>
|
||||
<span className={clsx("text-lg font-mono font-black", color)}>$ {value.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user