diff --git a/frontend/admin-panel/src/App.tsx b/frontend/admin-panel/src/App.tsx index c607fec..f43c63f 100644 --- a/frontend/admin-panel/src/App.tsx +++ b/frontend/admin-panel/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx b/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx index 4219e0d..52832a7 100644 --- a/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx +++ b/frontend/admin-panel/src/components/Listings/ListingDetailModal.tsx @@ -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({
Estado - {listing.status} + + {(() => { + 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); + })()} +
+ {listing.publicationStartDate && ( +
+ Fecha Inicio + {new Date(listing.publicationStartDate).toLocaleDateString()} +
+ )} + {listing.approvedAt && ( +
+ Aprobación + {new Date(listing.approvedAt).toLocaleDateString()} +
+ )} @@ -162,6 +190,13 @@ export default function ListingDetailModal({

"{listing.description || 'Sin descripción adicional.'}"

+ + {/* ACCIONES EXTRA (EJ: MODERACIÓN) */} + {children && ( +
+ {children} +
+ )} diff --git a/frontend/admin-panel/src/components/Modal.tsx b/frontend/admin-panel/src/components/Modal.tsx index 140c7cd..7b04522 100644 --- a/frontend/admin-panel/src/components/Modal.tsx +++ b/frontend/admin-panel/src/components/Modal.tsx @@ -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 (
-
+

{title}

-
+
{children}
diff --git a/frontend/admin-panel/src/layouts/ProtectedLayout.tsx b/frontend/admin-panel/src/layouts/ProtectedLayout.tsx index 5f187a8..4d6a013 100644 --- a/frontend/admin-panel/src/layouts/ProtectedLayout.tsx +++ b/frontend/admin-panel/src/layouts/ProtectedLayout.tsx @@ -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 ; + 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: , roles: ['Admin', 'Cajero'] }, - { label: 'Moderación', href: '/moderation', icon: , roles: ['Admin', 'Moderador'] }, + { label: 'Moderación', href: '/moderation', icon: , roles: ['Admin', 'Moderador'], badge: unreadCount }, { label: 'Explorador', href: '/listings', icon: , roles: ['Admin', 'Cajero', 'Moderador'] }, { label: 'Clientes', href: '/clients', icon: , roles: ['Admin', 'Cajero'] }, { label: 'Categorías', href: '/categories', icon: , roles: ['Admin'] }, { label: 'Usuarios', href: '/users', icon: , roles: ['Admin'] }, { label: 'Tarifas', href: '/pricing', icon: , roles: ['Admin'] }, + { label: 'Promociones', href: '/promotions', icon: , roles: ['Admin'] }, + { label: 'Cupones', href: '/coupons', icon: , roles: ['Admin'] }, { label: 'Diagramación', href: '/diagram', icon: , roles: ['Admin', 'Diagramador'] }, { label: 'Auditoría', href: '/audit', icon: , roles: ['Admin'] }, ]; @@ -37,17 +67,24 @@ export default function ProtectedLayout() { if (!item.roles.includes(role || '')) return null; return ( - - {item.icon} - {item.label} - +
+ {item.icon} + {item.label} +
+ {(item as any).badge > 0 && ( + + {(item as any).badge} + + )} + ); })} diff --git a/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx b/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx index 3ec2260..7e64b21 100644 --- a/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx +++ b/frontend/admin-panel/src/pages/Audit/AuditTimeline.tsx @@ -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([]); + 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 ; - if (action === 'Rechazar') return ; - return ; + 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: , color: 'bg-emerald-50 text-emerald-700 border-emerald-100', label: 'Aprobación' }; + + case 'REJECT_LISTING': + case 'Rejected': + case 'Rechazar': + return { icon: , 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: , 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: , 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: , color: 'bg-slate-100 text-slate-700 border-slate-200', label: 'Eliminación' }; + + case 'LOGIN': + return { icon: , color: 'bg-indigo-50 text-indigo-700 border-indigo-100', label: 'Acceso' }; + + case 'CONFIRM_PAYMENT': + return { icon: , color: 'bg-cyan-50 text-cyan-700 border-cyan-100', label: 'Pago' }; + + default: + return { icon: , color: 'bg-slate-50 text-slate-500 border-slate-200', label: action.replace('_', ' ') }; + } + }; + + + return ( -
-
- -

Auditoría de Actividad

+
+ {/* HEADER PREMIUM */} +
+
+

+ Auditoría de Sistema +

+

Trazabilidad completa de acciones y cambios de estado

+
-
-
- Últimas acciones realizadas por el equipo + {/* BARRA DE FILTROS */} +
+
+ +
-
+
+
+ + setFilters({ ...filters, from: e.target.value })} + /> +
+
+ + setFilters({ ...filters, to: e.target.value })} + /> +
+
+ + +
+ + {/* LISTA DE EVENTOS TIPO TIMELINE */} +
+ {/* Línea vertical de fondo para el timeline */} +
+ +
{loading ? ( -
Cargando historial...
- ) : logs.map(log => ( -
-
{getIcon(log.action)}
-
-
- - {log.username} - - - {new Date(log.createdAt).toLocaleString()} - -
-

{log.details}

-
+
+
+

Sincronizando logs...

- ))} + ) : logs.length === 0 ? ( +
+ +

No hay eventos para el período seleccionado

+
+ ) : logs.map((log, idx) => { + const meta = getLogMeta(log.action); + return ( +
+
+ + {/* HORA Y PUNTO (Timeline) */} +
+ {new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {new Date(log.createdAt).toLocaleDateString()} +
+ + {/* ICONO CENTRAL BUBBLE */} +
+ {meta.icon} +
+ + {/* CARD DE CONTENIDO */} +
+
+
+
+ {meta.label} + + {log.username} + +
+
+
+ + #{log.id} + +
+ {new Date(log.createdAt).toLocaleString()} +
+
+
+ +

+ {translateDetails(log.details)} +

+
+ +
+
+ ); + })}
-
+
); } \ No newline at end of file diff --git a/frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx b/frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx new file mode 100644 index 0000000..306d143 --- /dev/null +++ b/frontend/admin-panel/src/pages/Coupons/CouponsPage.tsx @@ -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([]); + 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
Cargando...
; + + return ( +
+
+

Gestión de Cupones

+ +
+ + {showForm && ( +
+
+ + setNewCoupon({ ...newCoupon, code: e.target.value })} + /> +
+
+ + +
+
+ + setNewCoupon({ ...newCoupon, discountValue: Number(e.target.value) })} + /> +
+
+ + setNewCoupon({ ...newCoupon, expiryDate: e.target.value })} + /> +
+
+ + setNewCoupon({ ...newCoupon, maxUsages: e.target.value })} + /> +
+
+ + setNewCoupon({ ...newCoupon, maxUsagesPerUser: e.target.value })} + /> +
+ +
+ )} + +
+ + + + + + + + + + + + {coupons.map(coupon => ( + + + + + + + + ))} + {coupons.length === 0 && ( + + + + )} + +
CódigoDescuentoUsosExpiraAcciones
+ + {coupon.code} + + {coupon.discountType === 'Percentage' ? `${coupon.discountValue}%` : `$${coupon.discountValue}`} + + {coupon.usageCount} {coupon.maxUsages ? `/ ${coupon.maxUsages}` : ''} + + {coupon.expiryDate ? formatDate(coupon.expiryDate) : '-'} + + +
No hay cupones activos.
+
+
+ ); +} diff --git a/frontend/admin-panel/src/pages/Dashboard.tsx b/frontend/admin-panel/src/pages/Dashboard.tsx index 4eff90b..30c70ee 100644 --- a/frontend/admin-panel/src/pages/Dashboard.tsx +++ b/frontend/admin-panel/src/pages/Dashboard.tsx @@ -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() {
- {t.status} + {translateStatus(t.status)}
diff --git a/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx b/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx index 5d50cfa..eff2852 100644 --- a/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx +++ b/frontend/admin-panel/src/pages/Listings/ListingExplorer.tsx @@ -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(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() {
- - {l.status} - + {(() => { + 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 ( + + Pendiente + + ); + } + + if (l.status === 'Rejected') { + return ( + + Rechazado + + ); + } + + if (l.status === 'Published') { + if (endDate && now > endDate) { + return ( + + Finalizado + + ); + } + + if (startDate && now < startDate) { + return ( + + Aprobado + + ); + } + + return ( + + Publicado + + ); + } + + return ( + + {translateStatus(l.status)} + + ); + })()} - - -
+
+
+ + +
+
+ + + +
+
))}
)} + + {/* Modal de Detalle Premium Reutilizado */} + setDetailModalOpen(false)} + detail={selectedListingDetail} + > +
+ + +
+
+ + {/* Modal de Nota / Rechazo */} + setNoteModalOpen(false)} title={actionType === 'Rejected' ? 'Rechazar Aviso' : actionType === 'Published' ? 'Aprobar con Nota' : 'Chat con el Usuario'}> +
+ {threadNotes.length > 0 && ( +
+

Historial de mensajes

+ {threadNotes.map((n: any) => ( +
+
+ {n.message} +
+ + {n.isFromModerator ? 'Moderador' : 'Usuario'} • {new Date(n.createdAt).toLocaleString()} + +
+ ))} +
+ )} + +

+ {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.'} +

+