Feat Varios 3
This commit is contained in:
@@ -12,6 +12,7 @@ import ModerationPage from './pages/Moderation/ModerationPage';
|
||||
import ListingExplorer from './pages/Listings/ListingExplorer';
|
||||
import AuditTimeline from './pages/Audit/AuditTimeline';
|
||||
import ClientManager from './pages/Clients/ClientManager';
|
||||
import CouponsPage from './pages/Coupons/CouponsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -29,6 +30,7 @@ function App() {
|
||||
<Route path="/diagram" element={<DiagramPage />} />
|
||||
<Route path="/pricing" element={<PricingManager />} />
|
||||
<Route path="/promotions" element={<PromotionsManager />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
<Route path="/reports/categories" element={<SalesByCategory />} />
|
||||
<Route path="/audit" element={<AuditTimeline />} />
|
||||
</Route>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Printer, Globe, Tag, Image as ImageIcon, Info, User } from 'lucide-react';
|
||||
import { translateStatus } from '../../utils/translations';
|
||||
|
||||
interface ListingDetail {
|
||||
listing: {
|
||||
@@ -10,6 +11,8 @@ interface ListingDetail {
|
||||
adFee: number;
|
||||
price: number;
|
||||
status: string;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
printText?: string;
|
||||
printDaysCount?: number;
|
||||
isBold?: boolean;
|
||||
@@ -23,11 +26,13 @@ interface ListingDetail {
|
||||
export default function ListingDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
detail
|
||||
detail,
|
||||
children
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
detail: ListingDetail | null
|
||||
detail: ListingDetail | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
if (!isOpen || !detail) return null;
|
||||
const { listing, attributes, images } = detail;
|
||||
@@ -124,8 +129,31 @@ export default function ListingDetailModal({
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Estado</span>
|
||||
<span className="font-bold uppercase text-xs tracking-widest">{listing.status}</span>
|
||||
<span className="font-bold uppercase text-[10px] text-right leading-tight">
|
||||
{(() => {
|
||||
if (listing.status === 'Published' && listing.publicationStartDate) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const start = new Date(listing.publicationStartDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
if (start > today) return "APROBADO";
|
||||
}
|
||||
return translateStatus(listing.status);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{listing.publicationStartDate && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Fecha Inicio</span>
|
||||
<span className="font-bold text-xs">{new Date(listing.publicationStartDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.approvedAt && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="opacity-60">Aprobación</span>
|
||||
<span className="font-bold text-xs">{new Date(listing.approvedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +190,13 @@ export default function ListingDetailModal({
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 italic">"{listing.description || 'Sin descripción adicional.'}"</p>
|
||||
</div>
|
||||
|
||||
{/* ACCIONES EXTRA (EJ: MODERACIÓN) */}
|
||||
{children && (
|
||||
<div className="pt-4 border-t border-gray-100">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,22 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md animate-fade-in">
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${maxWidth} max-h-[90vh] flex flex-col animate-fade-in`}>
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-red-500">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
LogOut, LayoutDashboard, FolderTree, Users,
|
||||
FileText, DollarSign, Eye, History, User as ClientIcon, Search
|
||||
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
const { isAuthenticated, role, logout } = useAuthStore();
|
||||
@@ -11,15 +13,43 @@ export default function ProtectedLayout() {
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && (role === 'Admin' || role === 'Moderador')) {
|
||||
const loadCount = async () => {
|
||||
try {
|
||||
const res = await api.get('/moderationstats/unread-count');
|
||||
setUnreadCount(res.data);
|
||||
} catch { /* silence */ }
|
||||
};
|
||||
|
||||
const handleUpdate = (e: any) => {
|
||||
if (e.detail !== undefined) setUnreadCount(e.detail);
|
||||
else loadCount();
|
||||
};
|
||||
|
||||
loadCount();
|
||||
window.addEventListener('moderation-unread-changed', handleUpdate);
|
||||
const interval = setInterval(loadCount, 60000);
|
||||
return () => {
|
||||
window.removeEventListener('moderation-unread-changed', handleUpdate);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [isAuthenticated, role]);
|
||||
|
||||
// Definición de permisos por ruta
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'] },
|
||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
||||
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
||||
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Promociones', href: '/promotions', icon: <Tag size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Cupones', href: '/coupons', icon: <Tag size={20} />, roles: ['Admin'] },
|
||||
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
|
||||
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] },
|
||||
];
|
||||
@@ -37,17 +67,24 @@ export default function ProtectedLayout() {
|
||||
if (!item.roles.includes(role || '')) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl transition-all ${location.pathname === item.href
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
to={item.href}
|
||||
className={`flex items-center justify-between p-3 rounded-xl transition-all ${location.pathname === item.href
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</a>
|
||||
<div className="flex items-center gap-3">
|
||||
{item.icon}
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</div>
|
||||
{(item as any).badge > 0 && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black ${location.pathname === item.href ? 'bg-white text-blue-600' : 'bg-rose-500 text-white'}`}>
|
||||
{(item as any).badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Clock } from 'lucide-react';
|
||||
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Search, Calendar, Users, AlertCircle, Info } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface AuditLog {
|
||||
id: number;
|
||||
@@ -8,58 +9,238 @@ interface AuditLog {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
details: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export default function AuditTimeline() {
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [users, setUsers] = useState<{ id: number, username: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/reports/audit').then(res => {
|
||||
setLogs(res.data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
// ESTADO DE FILTROS
|
||||
const [filters, setFilters] = useState({
|
||||
from: todayStr,
|
||||
to: todayStr,
|
||||
userId: ""
|
||||
});
|
||||
|
||||
const getIcon = (action: string) => {
|
||||
if (action === 'Aprobar') return <CheckCircle className="text-green-500" size={18} />;
|
||||
if (action === 'Rechazar') return <XCircle className="text-red-500" size={18} />;
|
||||
return <FileText className="text-blue-500" size={18} />;
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const res = await api.get('/users');
|
||||
setUsers(res.data);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/reports/audit', {
|
||||
params: {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
userId: filters.userId || null
|
||||
}
|
||||
});
|
||||
setLogs(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => { loadInitialData(); }, []);
|
||||
useEffect(() => { loadLogs(); }, [loadLogs]);
|
||||
|
||||
const translateDetails = (details: string) => {
|
||||
if (!details) return details;
|
||||
return details
|
||||
.replace(/Published/g, 'Publicado')
|
||||
.replace(/Pending/g, 'Pendiente')
|
||||
.replace(/Rejected/g, 'Rechazado')
|
||||
.replace(/Draft/g, 'Borrador')
|
||||
.replace(/True/g, 'Sí')
|
||||
.replace(/False/g, 'No')
|
||||
.replace(/Total:/g, 'Total:')
|
||||
.replace(/Featured:/g, 'Destacado:')
|
||||
.replace(/El usuario (\d+) cambió el estado del aviso #(\d+) a/g, 'Se cambió el estado del aviso #$2 a')
|
||||
.replace(/Aviso creado por usuario autenticado/g, 'Nuevo aviso creado');
|
||||
};
|
||||
|
||||
const getLogMeta = (action: string) => {
|
||||
switch (action) {
|
||||
case 'APPROVE_LISTING':
|
||||
case 'Published':
|
||||
case 'Aprobar':
|
||||
return { icon: <CheckCircle className="text-emerald-500" size={16} />, color: 'bg-emerald-50 text-emerald-700 border-emerald-100', label: 'Aprobación' };
|
||||
|
||||
case 'REJECT_LISTING':
|
||||
case 'Rejected':
|
||||
case 'Rechazar':
|
||||
return { icon: <XCircle className="text-rose-500" size={16} />, color: 'bg-rose-50 text-rose-700 border-rose-100', label: 'Rechazo' };
|
||||
|
||||
case 'CREATE_LISTING':
|
||||
case 'CREATE_USER':
|
||||
case 'CREATE_COUPON':
|
||||
case 'CREATE_CATEGORY':
|
||||
return { icon: <FileText className="text-blue-500" size={16} />, color: 'bg-blue-50 text-blue-700 border-blue-100', label: 'Creación' };
|
||||
|
||||
case 'UPDATE_USER':
|
||||
case 'UPDATE_CLIENT':
|
||||
case 'UPDATE_CATEGORY':
|
||||
case 'SAVE_PRICING':
|
||||
case 'UPDATE_PROMOTION':
|
||||
return { icon: <Search className="text-amber-500" size={16} />, color: 'bg-amber-50 text-amber-700 border-amber-100', label: 'Actualización' };
|
||||
|
||||
case 'DELETE_USER':
|
||||
case 'DELETE_COUPON':
|
||||
case 'DELETE_CATEGORY':
|
||||
case 'DELETE_PROMOTION':
|
||||
return { icon: <XCircle className="text-slate-600" size={16} />, color: 'bg-slate-100 text-slate-700 border-slate-200', label: 'Eliminación' };
|
||||
|
||||
case 'LOGIN':
|
||||
return { icon: <Users className="text-indigo-500" size={16} />, color: 'bg-indigo-50 text-indigo-700 border-indigo-100', label: 'Acceso' };
|
||||
|
||||
case 'CONFIRM_PAYMENT':
|
||||
return { icon: <CheckCircle className="text-cyan-500" size={16} />, color: 'bg-cyan-50 text-cyan-700 border-cyan-100', label: 'Pago' };
|
||||
|
||||
default:
|
||||
return { icon: <Info className="text-slate-400" size={16} />, color: 'bg-slate-50 text-slate-500 border-slate-200', label: action.replace('_', ' ') };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Auditoría de Actividad</h2>
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
{/* HEADER PREMIUM */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
|
||||
<History size={32} className="text-blue-600" /> Auditoría de Sistema
|
||||
</h2>
|
||||
<p className="text-slate-500 font-medium text-sm mt-1">Trazabilidad completa de acciones y cambios de estado</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="p-4 bg-gray-50 border-b font-bold text-gray-600 text-sm">
|
||||
Últimas acciones realizadas por el equipo
|
||||
{/* BARRA DE FILTROS */}
|
||||
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-xl shadow-slate-200/50 flex flex-wrap items-end gap-6">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Users size={12} /> Usuario Responsable
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2.5 font-bold text-sm outline-none focus:border-blue-500 transition-all appearance-none"
|
||||
value={filters.userId}
|
||||
onChange={e => setFilters({ ...filters, userId: e.target.value })}
|
||||
>
|
||||
<option value="">TODOS LOS USUARIOS</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-[2]">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Calendar size={12} /> Desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
||||
value={filters.from}
|
||||
onChange={e => setFilters({ ...filters, from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||
<Calendar size={12} /> Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
||||
value={filters.to}
|
||||
onChange={e => setFilters({ ...filters, to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadLogs}
|
||||
className="bg-slate-900 text-white p-3 rounded-xl hover:bg-black transition-all shadow-lg shadow-slate-200"
|
||||
>
|
||||
<Search size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LISTA DE EVENTOS TIPO TIMELINE */}
|
||||
<div className="relative">
|
||||
{/* Línea vertical de fondo para el timeline */}
|
||||
<div className="absolute left-12 top-0 bottom-0 w-0.5 bg-slate-100 hidden md:block"></div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="p-10 text-center text-gray-400 italic">Cargando historial...</div>
|
||||
) : logs.map(log => (
|
||||
<div key={log.id} className="p-4 hover:bg-gray-50 transition flex items-start gap-4">
|
||||
<div className="mt-1">{getIcon(log.action)}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold text-gray-800 flex items-center gap-1 italic">
|
||||
<UserIcon size={14} className="text-gray-400" /> {log.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} /> {new Date(log.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{log.details}</p>
|
||||
</div>
|
||||
<div className="py-20 text-center flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-slate-100 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Sincronizando logs...</p>
|
||||
</div>
|
||||
))}
|
||||
) : logs.length === 0 ? (
|
||||
<div className="bg-slate-50 rounded-[2rem] border border-dashed border-slate-200 p-20 text-center text-slate-400">
|
||||
<AlertCircle size={40} className="mx-auto mb-4 opacity-20" />
|
||||
<p className="font-bold uppercase text-xs tracking-widest">No hay eventos para el período seleccionado</p>
|
||||
</div>
|
||||
) : logs.map((log, idx) => {
|
||||
const meta = getLogMeta(log.action);
|
||||
return (
|
||||
<div key={log.id} className="relative group animate-fade-in" style={{ animationDelay: `${idx * 50}ms` }}>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
|
||||
{/* HORA Y PUNTO (Timeline) */}
|
||||
<div className="hidden md:flex flex-col items-end w-24 flex-shrink-0">
|
||||
<span className="text-xs font-black text-slate-900">{new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{new Date(log.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* ICONO CENTRAL BUBBLE */}
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center border-4 border-white shadow-sm z-10 transition-transform group-hover:scale-110",
|
||||
meta.color.split(' ')[0]
|
||||
)}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
|
||||
{/* CARD DE CONTENIDO */}
|
||||
<div className="flex-1 bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm group-hover:shadow-md group-hover:border-blue-100 transition-all">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-widest mb-0.5">{meta.label}</span>
|
||||
<span className="text-sm font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
|
||||
<UserIcon size={14} className="text-slate-300" /> {log.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-slate-50 rounded-full text-[9px] font-black text-slate-400 uppercase tracking-wider border border-slate-100">
|
||||
#{log.id}
|
||||
</span>
|
||||
<div className="md:hidden text-[10px] font-bold text-slate-400">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-slate-600 leading-relaxed bg-slate-50/50 p-3 rounded-xl border border-slate-50 italic">
|
||||
{translateDetails(log.details)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
205
frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx
Normal file
205
frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Trash2, Plus, Tag } from 'lucide-react';
|
||||
import { formatDate } from '../../utils/formatters';
|
||||
|
||||
interface Coupon {
|
||||
id: number;
|
||||
code: string;
|
||||
discountType: 'Percentage' | 'Fixed';
|
||||
discountValue: number;
|
||||
expiryDate?: string;
|
||||
usageCount: number;
|
||||
maxUsages?: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export default function CouponsPage() {
|
||||
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [newCoupon, setNewCoupon] = useState({
|
||||
code: '',
|
||||
discountType: 'Percentage',
|
||||
discountValue: 0,
|
||||
expiryDate: '',
|
||||
maxUsages: '',
|
||||
maxUsagesPerUser: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadCoupons();
|
||||
}, []);
|
||||
|
||||
const loadCoupons = async () => {
|
||||
try {
|
||||
const res = await api.get('/coupons');
|
||||
setCoupons(res.data);
|
||||
} catch (error) {
|
||||
console.error("Error loading coupons", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('¿Eliminar cupón?')) return;
|
||||
try {
|
||||
await api.delete(`/coupons/${id}`);
|
||||
loadCoupons();
|
||||
} catch (error) {
|
||||
alert("Error al eliminar");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = {
|
||||
...newCoupon,
|
||||
maxUsages: newCoupon.maxUsages ? parseInt(newCoupon.maxUsages) : null,
|
||||
maxUsagesPerUser: newCoupon.maxUsagesPerUser ? parseInt(newCoupon.maxUsagesPerUser) : null,
|
||||
expiryDate: newCoupon.expiryDate ? newCoupon.expiryDate : null
|
||||
};
|
||||
await api.post('/coupons', payload);
|
||||
setShowForm(false);
|
||||
setNewCoupon({ code: '', discountType: 'Percentage', discountValue: 0, expiryDate: '', maxUsages: '', maxUsagesPerUser: '' });
|
||||
loadCoupons();
|
||||
} catch (error) {
|
||||
alert("Error al crear cupón");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-10 text-center">Cargando...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Gestión de Cupones</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
|
||||
>
|
||||
<Plus size={18} /> Nuevo Cupón
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleCreate} className="bg-white p-6 rounded-xl shadow border border-slate-100 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Código</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
className="w-full border p-2 rounded"
|
||||
placeholder="Ej: CUPON2026"
|
||||
value={newCoupon.code}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, code: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Tipo Descuento</label>
|
||||
<select
|
||||
className="w-full border p-2 rounded"
|
||||
value={newCoupon.discountType}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, discountType: e.target.value })}
|
||||
>
|
||||
<option value="Percentage">Porcentaje (%)</option>
|
||||
<option value="Fixed">Monto Fijo ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Valor</label>
|
||||
<input
|
||||
required
|
||||
type="number"
|
||||
className="w-full border p-2 rounded"
|
||||
value={newCoupon.discountValue}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, discountValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Expiración (Opcional)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full border p-2 rounded"
|
||||
value={newCoupon.expiryDate}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, expiryDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Usos Máx.</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border p-2 rounded"
|
||||
placeholder="Ilimitado"
|
||||
value={newCoupon.maxUsages}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, maxUsages: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">Límite x Usuario</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border p-2 rounded"
|
||||
placeholder="Ilimitado"
|
||||
value={newCoupon.maxUsagesPerUser}
|
||||
onChange={e => setNewCoupon({ ...newCoupon, maxUsagesPerUser: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-emerald-500 text-white p-2 rounded font-bold hover:bg-emerald-600">
|
||||
Guardar
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Código</th>
|
||||
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Descuento</th>
|
||||
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Usos</th>
|
||||
<th className="p-4 font-bold text-slate-600 text-xs uppercase">Expira</th>
|
||||
<th className="p-4 font-bold text-slate-600 text-xs uppercase text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coupons.map(coupon => (
|
||||
<tr key={coupon.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50">
|
||||
<td className="p-4 font-mono font-bold text-slate-800 flex items-center gap-2">
|
||||
<Tag size={14} className="text-blue-500" />
|
||||
{coupon.code}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
{coupon.discountType === 'Percentage' ? `${coupon.discountValue}%` : `$${coupon.discountValue}`}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
{coupon.usageCount} {coupon.maxUsages ? `/ ${coupon.maxUsages}` : ''}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-600">
|
||||
{coupon.expiryDate ? formatDate(coupon.expiryDate) : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(coupon.id)}
|
||||
className="text-rose-400 hover:text-rose-600 transition"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{coupons.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-400 text-sm">No hay cupones activos.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { dashboardService, type DashboardData } from '../services/dashboardService';
|
||||
import { translateStatus } from '../utils/translations';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import api from '../services/api';
|
||||
import { reportService } from '../services/reportService';
|
||||
@@ -330,7 +331,7 @@ export default function Dashboard() {
|
||||
<td className="p-4">
|
||||
<span className={`flex items-center gap-1.5 font-bold ${t.status === 'Published' ? 'text-green-600' : 'text-orange-500'}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${t.status === 'Published' ? 'bg-green-600' : 'bg-orange-500'}`}></div>
|
||||
{t.status}
|
||||
{translateStatus(t.status)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
X, Monitor, Globe, Download
|
||||
} from 'lucide-react';
|
||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||
import { translateStatus } from '../../utils/translations';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function ListingExplorer() {
|
||||
@@ -19,14 +20,15 @@ export default function ListingExplorer() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDetail, setSelectedDetail] = useState<ListingDetail | null>(null);
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
// ESTADO DE FILTROS
|
||||
const [filters, setFilters] = useState({
|
||||
query: searchParams.get('q') || "",
|
||||
categoryId: "",
|
||||
origin: "All",
|
||||
status: "",
|
||||
from: "",
|
||||
to: ""
|
||||
from: todayStr,
|
||||
to: todayStr
|
||||
});
|
||||
|
||||
const loadInitialData = async () => {
|
||||
@@ -35,6 +37,7 @@ export default function ListingExplorer() {
|
||||
};
|
||||
|
||||
const loadListings = useCallback(async () => {
|
||||
if (!filters.from || !filters.to) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post('/listings/search', {
|
||||
@@ -42,8 +45,8 @@ export default function ListingExplorer() {
|
||||
categoryId: filters.categoryId ? parseInt(filters.categoryId) : null,
|
||||
origin: filters.origin,
|
||||
status: filters.status,
|
||||
from: filters.from || null,
|
||||
to: filters.to || null
|
||||
from: filters.from,
|
||||
to: filters.to
|
||||
});
|
||||
setListings(res.data);
|
||||
} finally {
|
||||
@@ -61,7 +64,7 @@ export default function ListingExplorer() {
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: "", to: "" });
|
||||
setFilters({ query: "", categoryId: "", origin: "All", status: "", from: todayStr, to: todayStr });
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
@@ -231,14 +234,63 @@ export default function ListingExplorer() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-5 text-center">
|
||||
<span className={clsx(
|
||||
"px-2 py-1 rounded-full text-[9px] font-black uppercase border",
|
||||
l.status === 'Published' ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
|
||||
l.status === 'Pending' ? "bg-amber-50 text-amber-700 border-amber-200" :
|
||||
"bg-slate-50 text-slate-500 border-slate-200"
|
||||
)}>
|
||||
{l.status}
|
||||
</span>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = l.publicationStartDate ? new Date(l.publicationStartDate) : (l.approvedAt ? new Date(l.approvedAt) : null);
|
||||
const days = l.printDaysCount || 0;
|
||||
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
|
||||
if (l.status === 'Pending') {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-amber-50 text-amber-700 border-amber-200">
|
||||
Pendiente
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (l.status === 'Rejected') {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-rose-50 text-rose-700 border-rose-200">
|
||||
Rechazado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (l.status === 'Published') {
|
||||
if (endDate && now > endDate) {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-100 text-slate-500 border-slate-300">
|
||||
Finalizado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (startDate && now < startDate) {
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-blue-50 text-blue-700 border-blue-200">
|
||||
Aprobado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
Publicado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-[9px] font-black uppercase border bg-slate-50 text-slate-500 border-slate-200">
|
||||
{translateStatus(l.status)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="p-5 text-right">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Check, X, Printer, Globe, MessageSquare } from 'lucide-react';
|
||||
import { Check, X, Printer, Globe, Eye, MessageSquare } from 'lucide-react';
|
||||
import Modal from '../../components/Modal';
|
||||
import ListingDetailModal from '../../components/Listings/ListingDetailModal';
|
||||
|
||||
interface Listing {
|
||||
id: number;
|
||||
@@ -14,18 +16,41 @@ interface Listing {
|
||||
isBold: boolean;
|
||||
isFrame: boolean;
|
||||
printDaysCount: number;
|
||||
unreadNotesCount: number;
|
||||
categoryName?: string;
|
||||
parentCategoryName?: string;
|
||||
}
|
||||
|
||||
export default function ModerationPage() {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => { loadPending(); }, []);
|
||||
// Estados para los Modales
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedListingDetail, setSelectedListingDetail] = useState<any>(null);
|
||||
|
||||
const [noteModalOpen, setNoteModalOpen] = useState(false);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [actionListingId, setActionListingId] = useState<number | null>(null);
|
||||
const [actionType, setActionType] = useState<'Published' | 'Rejected' | null>(null);
|
||||
const [threadNotes, setThreadNotes] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPending();
|
||||
loadUnreadCount();
|
||||
}, []);
|
||||
|
||||
const loadUnreadCount = async () => {
|
||||
try {
|
||||
const res = await api.get('/moderationstats/unread-count');
|
||||
// Notificar al sidebar
|
||||
window.dispatchEvent(new CustomEvent('moderation-unread-changed', { detail: res.data }));
|
||||
} catch { /* silence */ }
|
||||
};
|
||||
|
||||
const loadPending = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Este endpoint ya lo tenemos en el ListingsController que hicimos al inicio
|
||||
const res = await api.get('/listings/pending');
|
||||
setListings(res.data);
|
||||
} catch {
|
||||
@@ -34,24 +59,84 @@ export default function ModerationPage() {
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleAction = async (id: number, action: 'Published' | 'Rejected') => {
|
||||
const handleOpenDetail = async (id: number) => {
|
||||
try {
|
||||
await api.put(`/listings/${id}/status`, JSON.stringify(action), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const res = await api.get(`/listings/${id}`);
|
||||
setSelectedListingDetail(res.data);
|
||||
setDetailModalOpen(true);
|
||||
} catch {
|
||||
alert("Error al cargar detalles del aviso");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAction = (id: number, type: 'Published' | 'Rejected') => {
|
||||
setActionListingId(id);
|
||||
setActionType(type);
|
||||
setNoteText('');
|
||||
|
||||
if (type === 'Rejected') {
|
||||
setNoteModalOpen(true);
|
||||
} else {
|
||||
confirmAction(id, type);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAction = async (id: number, type: 'Published' | 'Rejected', note?: string) => {
|
||||
try {
|
||||
const payload = { status: type, note };
|
||||
await api.put(`/listings/${id}/status`, payload);
|
||||
|
||||
setListings(listings.filter(l => l.id !== id));
|
||||
setNoteModalOpen(false);
|
||||
setDetailModalOpen(false);
|
||||
} catch {
|
||||
alert("Error al procesar el aviso");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitNote = async () => {
|
||||
if (!actionListingId) return;
|
||||
|
||||
try {
|
||||
if (actionType) {
|
||||
confirmAction(actionListingId, actionType, noteText);
|
||||
} else {
|
||||
await api.post(`/listings/${actionListingId}/notes`, { message: noteText });
|
||||
setNoteModalOpen(false);
|
||||
setNoteText('');
|
||||
loadUnreadCount();
|
||||
alert('Nota enviada al usuario correctamente');
|
||||
}
|
||||
} catch {
|
||||
alert('Error al enviar la nota');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenNoteModal = async (id: number, type: 'Published' | 'Rejected' | null) => {
|
||||
setActionListingId(id);
|
||||
setActionType(type);
|
||||
setNoteText('');
|
||||
setNoteModalOpen(true);
|
||||
|
||||
try {
|
||||
const res = await api.get(`/listings/${id}/notes`);
|
||||
setThreadNotes(res.data);
|
||||
loadUnreadCount();
|
||||
setListings(prev => prev.map(l => l.id === id ? { ...l, unreadNotesCount: 0 } : l));
|
||||
} catch {
|
||||
setThreadNotes([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-10 text-center text-gray-500">Cargando avisos para revisar...</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Panel de Moderación</h2>
|
||||
<p className="text-gray-500">Revisión de avisos entrantes para Web y Diario Papel</p>
|
||||
<div className="mb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-gray-900 tracking-tighter uppercase leading-none mb-2">Panel de Moderación</h2>
|
||||
<p className="text-gray-500 font-medium">Revisión de avisos entrantes para Web y Diario Papel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listings.length === 0 ? (
|
||||
@@ -61,74 +146,159 @@ export default function ModerationPage() {
|
||||
<p className="text-gray-500">No hay avisos pendientes de moderación en este momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{listings.map(listing => (
|
||||
<div key={listing.id} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col md:flex-row">
|
||||
<div key={listing.id} className={`bg-white rounded-xl shadow-sm border ${listing.unreadNotesCount > 0 ? 'border-blue-400 ring-1 ring-blue-100' : 'border-gray-200'} overflow-hidden flex flex-col md:flex-row relative group`}>
|
||||
|
||||
{/* Info del Aviso */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded uppercase tracking-wider">
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded uppercase tracking-wider border border-blue-100 flex items-center gap-1.5">
|
||||
ID: #{listing.id}
|
||||
{listing.unreadNotesCount > 0 && (
|
||||
<span className="flex items-center gap-1 bg-blue-600 text-white px-1.5 py-0.5 rounded-md animate-pulse">
|
||||
<MessageSquare size={8} fill="white" />
|
||||
{listing.unreadNotesCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<h3 className="text-xl font-bold text-gray-900 mt-1">{listing.title}</h3>
|
||||
<h3 className="text-lg font-black text-gray-900 truncate max-w-md">{listing.title}</h3>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-400">{new Date(listing.createdAt).toLocaleString()}</p>
|
||||
<p className="text-sm font-black text-gray-900">{listing.currency} ${listing.price.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Versión Web */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-700 font-bold text-sm">
|
||||
<Globe size={16} /> VERSIÓN WEB / MOBILE
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-lg border border-gray-100 text-[13px] leading-relaxed">
|
||||
<div className="flex items-center gap-2 mb-1 text-gray-400 font-black text-[9px] uppercase tracking-widest">
|
||||
<Globe size={12} /> Web / Mobile
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 italic line-clamp-3">"{listing.description}"</p>
|
||||
<p className="text-gray-600 italic line-clamp-2">"{listing.description}"</p>
|
||||
</div>
|
||||
|
||||
{/* Versión Impresa - CRÍTICO */}
|
||||
<div className={`p-4 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-gray-700 font-bold text-sm">
|
||||
<Printer size={16} /> VERSIÓN IMPRESA
|
||||
<div className={`p-3 rounded-lg border-2 ${listing.isFrame ? 'border-black' : 'border-gray-200'} bg-white text-[13px]`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 text-gray-400 font-black text-[9px] uppercase tracking-widest">
|
||||
<Printer size={12} /> Diario Papel
|
||||
</div>
|
||||
<span className="text-[10px] bg-gray-100 px-2 py-0.5 rounded font-bold">
|
||||
{listing.printDaysCount} DÍAS
|
||||
<span className="text-[9px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-black uppercase">
|
||||
{listing.printDaysCount} días
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm p-2 bg-gray-50 rounded border border-dashed border-gray-300 ${listing.isBold ? 'font-bold' : ''}`}>
|
||||
{listing.printText || "Sin texto de impresión definido"}
|
||||
<div className={`p-1 bg-gray-50 rounded border border-dashed border-gray-200 line-clamp-2 ${listing.isBold ? 'font-bold' : ''}`}>
|
||||
{listing.printText || "Sin texto..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acciones Laterales */}
|
||||
<div className="bg-gray-50 border-t md:border-t-0 md:border-l border-gray-200 p-6 flex flex-row md:flex-col gap-3 justify-center min-w-[200px]">
|
||||
<button
|
||||
onClick={() => handleAction(listing.id, 'Published')}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition shadow-sm"
|
||||
>
|
||||
<Check size={20} /> APROBAR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(listing.id, 'Rejected')}
|
||||
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-bold py-3 px-4 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<X size={20} /> RECHAZAR
|
||||
</button>
|
||||
<button className="p-3 text-gray-400 hover:text-gray-600 flex items-center justify-center gap-2 text-xs font-medium">
|
||||
<MessageSquare size={16} /> Enviar Nota
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-50/50 border-t md:border-t-0 md:border-l border-gray-200 p-4 flex flex-row md:flex-col gap-2 justify-center min-w-[180px]">
|
||||
<div className="flex gap-2 md:flex-col w-full">
|
||||
<button
|
||||
onClick={() => handleOpenAction(listing.id, 'Published')}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition shadow-sm shadow-green-100"
|
||||
>
|
||||
<Check size={18} /> Aprobar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenAction(listing.id, 'Rejected')}
|
||||
className="flex-1 bg-white hover:bg-red-50 text-red-600 border border-red-200 font-black text-[11px] uppercase tracking-widest py-2.5 px-3 rounded-lg flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<X size={18} /> Rechazar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full mt-1">
|
||||
<button
|
||||
onClick={() => handleOpenNoteModal(listing.id, null)}
|
||||
className={`flex-1 py-2 rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition border ${listing.unreadNotesCount > 0
|
||||
? 'bg-blue-600 text-white border-blue-700 shadow-md shadow-blue-100'
|
||||
: 'text-slate-600 hover:bg-slate-100 border-transparent'}`}
|
||||
>
|
||||
<MessageSquare size={14} fill={listing.unreadNotesCount > 0 ? "white" : "none"} />
|
||||
{listing.unreadNotesCount > 0 ? 'Responder' : 'Nota'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenDetail(listing.id)}
|
||||
className="flex-1 py-2 text-blue-600 hover:bg-blue-50 border border-transparent rounded-lg flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest transition"
|
||||
>
|
||||
<Eye size={14} /> Detalle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Detalle Premium Reutilizado */}
|
||||
<ListingDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => setDetailModalOpen(false)}
|
||||
detail={selectedListingDetail}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Published')}
|
||||
className="w-full py-4 bg-green-600 text-white font-black uppercase text-xs tracking-widest rounded-2xl shadow-lg shadow-green-100 hover:bg-green-700 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check size={18} /> Aprobar aviso
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenAction(selectedListingDetail!.listing.id, 'Rejected')}
|
||||
className="w-full py-3 bg-white text-red-600 border border-red-100 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-red-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<X size={16} /> Rechazar aviso
|
||||
</button>
|
||||
</div>
|
||||
</ListingDetailModal>
|
||||
|
||||
{/* Modal de Nota / Rechazo */}
|
||||
<Modal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} title={actionType === 'Rejected' ? 'Rechazar Aviso' : actionType === 'Published' ? 'Aprobar con Nota' : 'Chat con el Usuario'}>
|
||||
<div className="space-y-4">
|
||||
{threadNotes.length > 0 && (
|
||||
<div className="bg-gray-50 p-4 rounded-xl border border-gray-100 max-h-64 overflow-y-auto space-y-3 mb-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Historial de mensajes</p>
|
||||
{threadNotes.map((n: any) => (
|
||||
<div key={n.id} className={`flex flex-col ${n.isFromModerator ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`max-w-[80%] p-3 rounded-2xl text-sm ${n.isFromModerator ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-700 rounded-tl-none'}`}>
|
||||
{n.message}
|
||||
</div>
|
||||
<span className="text-[9px] font-bold text-gray-400 mt-1 uppercase">
|
||||
{n.isFromModerator ? 'Moderador' : 'Usuario'} • {new Date(n.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
{actionType === 'Rejected'
|
||||
? 'Por favor indica la razón del rechazo para notificar al usuario.'
|
||||
: actionType === 'Published'
|
||||
? 'Opcionalmente, agrega una nota al aprobar este aviso.'
|
||||
: 'Escribe un mensaje al usuario. Recibirá una notificación en su panel.'}
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full border rounded-2xl p-4 text-sm h-32 focus:ring-2 focus:ring-blue-500 outline-none transition-all"
|
||||
placeholder="Escribe tu mensaje aquí..."
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setNoteModalOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl font-bold transition-all">Cancelar</button>
|
||||
<button
|
||||
onClick={handleSubmitNote}
|
||||
className={`px-6 py-2.5 rounded-xl text-white font-black uppercase text-xs tracking-widest transition-all ${actionType === 'Rejected' ? 'bg-red-600 hover:bg-red-700 shadow-lg shadow-red-200' : 'bg-blue-600 hover:bg-blue-700 shadow-lg shadow-blue-200'}`}
|
||||
>
|
||||
{actionType === 'Rejected' ? 'Confirmar Rechazo' : actionType === 'Published' ? 'Aprobar' : 'Enviar Mensaje'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ export default function UserManager() {
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Cajero">Cajero</option>
|
||||
<option value="Usuario">Usuario</option>
|
||||
<option value="Moderador">Moderador</option>
|
||||
<option value="Diagramador">Diagramador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface Listing {
|
||||
viewCount: number;
|
||||
origin: string;
|
||||
overlayStatus?: string | null;
|
||||
printDaysCount?: number;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListingAttribute {
|
||||
|
||||
16
frontend/admin-panel/src/utils/formatters.ts
Normal file
16
frontend/admin-panel/src/utils/formatters.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export const formatCurrency = (amount: number, currency: string = 'ARS') => {
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
};
|
||||
22
frontend/admin-panel/src/utils/translations.ts
Normal file
22
frontend/admin-panel/src/utils/translations.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/utils/translations.ts
|
||||
|
||||
export const translateStatus = (status: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
'Published': 'Publicado',
|
||||
'Pending': 'Pendiente',
|
||||
'Rejected': 'Rechazado',
|
||||
'Draft': 'Borrador',
|
||||
'Drafted': 'Borrador',
|
||||
'Review': 'En Revisión',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
export const translateOrigin = (origin: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
'Web': 'Web',
|
||||
'Mostrador': 'Mostrador',
|
||||
'All': 'Todos',
|
||||
};
|
||||
return map[origin] || origin;
|
||||
};
|
||||
Reference in New Issue
Block a user