Feat: Papelera de Avisos

- Se añade la sección de Papelera de Avisos para los avisos eliminados que serán removidos de los registros a los 60 días del cambio de estado. Es esta sección se permite restaurar un aviso eliminado al estado "Borrador".
This commit is contained in:
2026-02-26 20:17:52 -03:00
parent df777400ab
commit 0802dae400
5 changed files with 189 additions and 17 deletions

View File

@@ -43,6 +43,12 @@ public class AdminController : ControllerBase
.AsNoTracking() // Optimización de lectura .AsNoTracking() // Optimización de lectura
.AsQueryable(); .AsQueryable();
// Por defecto, ocultar eliminados a menos que se pida explícitamente
if (statusId != (int)AdStatusEnum.Deleted)
{
query = query.Where(a => a.StatusID != (int)AdStatusEnum.Deleted);
}
// Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario) // Filtro por Texto (Marca, Modelo, Email Usuario, Nombre Usuario)
if (!string.IsNullOrEmpty(q)) if (!string.IsNullOrEmpty(q))
{ {

View File

@@ -119,7 +119,7 @@ public class AdsV2Controller : ControllerBase
} }
else else
{ {
query = query.Where(a => a.UserID == userId.Value); query = query.Where(a => a.UserID == userId.Value && a.StatusID != (int)AdStatusEnum.Deleted);
} }
// --- LÓGICA DE BÚSQUEDA POR PALABRAS --- // --- LÓGICA DE BÚSQUEDA POR PALABRAS ---
@@ -763,6 +763,7 @@ public class AdsV2Controller : ControllerBase
var ads = await _context.Favorites var ads = await _context.Favorites
.Where(f => f.UserID == userId) .Where(f => f.UserID == userId)
.Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a) .Join(_context.Ads, f => f.AdID, a => a.AdID, (f, a) => a)
.Where(a => a.StatusID != (int)AdStatusEnum.Deleted)
.Include(a => a.Photos) .Include(a => a.Photos)
.Select(a => new .Select(a => new
{ {
@@ -824,6 +825,11 @@ public class AdsV2Controller : ControllerBase
int oldStatus = ad.StatusID; int oldStatus = ad.StatusID;
ad.StatusID = newStatus; ad.StatusID = newStatus;
if (newStatus == (int)AdStatusEnum.Deleted)
{
ad.DeletedAt = DateTime.UtcNow;
}
// 📝 AUDITORÍA // 📝 AUDITORÍA
var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? ""; var statusBrandName = (await _context.Brands.FindAsync(ad.BrandID))?.Name ?? "";
_context.AuditLogs.Add(new AuditLog _context.AuditLogs.Add(new AuditLog

View File

@@ -74,8 +74,12 @@ public class ChatController : ControllerBase
public async Task<IActionResult> GetInbox(int userId) public async Task<IActionResult> GetInbox(int userId)
{ {
// Obtener todas las conversaciones donde el usuario es remitente o destinatario // Obtener todas las conversaciones donde el usuario es remitente o destinatario
// Pero filtramos los que pertenecen a avisos eliminados (StatusID != 9)
var messages = await _context.ChatMessages var messages = await _context.ChatMessages
.Where(m => m.SenderID == userId || m.ReceiverID == userId) .Where(m => m.SenderID == userId || m.ReceiverID == userId)
.Join(_context.Ads, m => m.AdID, a => a.AdID, (m, a) => new { m, a })
.Where(x => x.a.StatusID != (int)AdStatusEnum.Deleted)
.Select(x => x.m)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.ToListAsync(); .ToListAsync();
@@ -119,7 +123,8 @@ public class ChatController : ControllerBase
} }
var count = await _context.ChatMessages var count = await _context.ChatMessages
.CountAsync(m => m.ReceiverID == userId && !m.IsRead); .Join(_context.Ads, m => m.AdID, a => a.AdID, (m, a) => new { m, a })
.CountAsync(x => x.m.ReceiverID == userId && !x.m.IsRead && x.a.StatusID != (int)AdStatusEnum.Deleted);
return Ok(new { count }); return Ok(new { count });
} }

View File

@@ -63,7 +63,7 @@ export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: s
icon: '📝' icon: '📝'
}, },
[AD_STATUSES.DELETED]: { [AD_STATUSES.DELETED]: {
label: 'Eliminar', label: 'Eliminado',
color: 'text-white', color: 'text-white',
bg: 'bg-red-700/90', bg: 'bg-red-700/90',
border: 'border-red-500/50', border: 'border-red-500/50',

View File

@@ -9,7 +9,7 @@ import AdDetailsModal from '../components/AdDetailsModal';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal'; import ConfirmationModal from '../components/ConfirmationModal';
type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit'; type TabType = 'stats' | 'ads' | 'moderation' | 'transactions' | 'users' | 'audit' | 'trash';
export default function AdminPage() { export default function AdminPage() {
const [activeTab, setActiveTab] = useState<TabType>('stats'); const [activeTab, setActiveTab] = useState<TabType>('stats');
@@ -45,8 +45,8 @@ export default function AdminPage() {
let isDanger = false; let isDanger = false;
if (newStatus === AD_STATUSES.DELETED) { if (newStatus === AD_STATUSES.DELETED) {
title = "¿Eliminar Aviso?"; title = "¿Mover a la Papelera?";
message = "Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?"; message = "El aviso se ocultará de los listados y se moverá a la Papelera. Se mantendrá allí por 60 días antes de su eliminación definitiva.\n\n¿Estás seguro de continuar?";
isDanger = true; isDanger = true;
} else if (newStatus === AD_STATUSES.PAUSED) { } else if (newStatus === AD_STATUSES.PAUSED) {
title = "Pausar Publicación"; title = "Pausar Publicación";
@@ -179,6 +179,13 @@ export default function AdminPage() {
page: adsFilters.page page: adsFilters.page
}); });
break; break;
case 'trash':
res = await AdminService.getAllAds({
q: adsFilters.q,
statusId: 9, // Forzamos 9 para papelera
page: adsFilters.page
});
break;
} }
setData(res); setData(res);
} catch (err) { } catch (err) {
@@ -222,20 +229,20 @@ export default function AdminPage() {
className={`w-full flex items-center justify-between bg-white/5 p-4 rounded-2xl border backdrop-blur-xl text-white font-black uppercase tracking-widest text-xs transition-all ${isMobileMenuOpen ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-white/10'}`} className={`w-full flex items-center justify-between bg-white/5 p-4 rounded-2xl border backdrop-blur-xl text-white font-black uppercase tracking-widest text-xs transition-all ${isMobileMenuOpen ? 'border-blue-500 ring-2 ring-blue-500/20' : 'border-white/10'}`}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{activeTab === 'stats' ? '📊 Resumen' : activeTab === 'ads' ? '📦 Avisos' : activeTab === 'moderation' ? '🛡️ Moderación' : activeTab === 'transactions' ? '💰 Pagos' : activeTab === 'users' ? '👥 Usuarios' : '📋 Auditoría'} {activeTab === 'stats' ? '📊 Resumen' : activeTab === 'ads' ? '📦 Avisos' : activeTab === 'moderation' ? '🛡️ Moderación' : activeTab === 'transactions' ? '💰 Pagos' : activeTab === 'users' ? '👥 Usuarios' : activeTab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</span> </span>
<span className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}></span> <span className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}></span>
</button> </button>
{isMobileMenuOpen && ( {isMobileMenuOpen && (
<div className="absolute top-full left-0 right-0 mt-2 bg-[#12141a] border border-white/10 rounded-2xl overflow-hidden z-[100] shadow-2xl animate-scale-up"> <div className="absolute top-full left-0 right-0 mt-2 bg-[#12141a] border border-white/10 rounded-2xl overflow-hidden z-[100] shadow-2xl animate-scale-up">
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => ( {(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
<button <button
key={tab} key={tab}
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`w-full text-left px-6 py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b border-white/5 last:border-0 ${activeTab === tab ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-white/5'}`} className={`w-full text-left px-6 py-4 text-[10px] font-black uppercase tracking-widest transition-all border-b border-white/5 last:border-0 ${activeTab === tab ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-white/5'}`}
> >
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'} {tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</button> </button>
))} ))}
</div> </div>
@@ -244,13 +251,13 @@ export default function AdminPage() {
{/* Menú tradicional para Escritorio */} {/* Menú tradicional para Escritorio */}
<div className="hidden md:flex bg-white/5 p-1.5 rounded-2xl border border-white/5 backdrop-blur-xl"> <div className="hidden md:flex bg-white/5 p-1.5 rounded-2xl border border-white/5 backdrop-blur-xl">
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => ( {(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
<button <button
key={tab} key={tab}
onClick={() => handleTabChange(tab)} onClick={() => handleTabChange(tab)}
className={`px-5 md:px-6 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`} className={`px-5 md:px-6 py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === tab ? 'bg-blue-600 text-white shadow-lg shadow-blue-600/20' : 'text-gray-500 hover:text-white'}`}
> >
{tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : '📋 Auditoría'} {tab === 'stats' ? '📊 Resumen' : tab === 'ads' ? '📦 Avisos' : tab === 'moderation' ? '🛡️ Moderación' : tab === 'transactions' ? '💰 Pagos' : tab === 'users' ? '👥 Usuarios' : tab === 'audit' ? '📋 Auditoría' : '🗑️ Papelera'}
</button> </button>
))} ))}
</div> </div>
@@ -341,8 +348,10 @@ export default function AdminPage() {
onChange={e => setAdsFilters({ ...adsFilters, statusId: e.target.value })} onChange={e => setAdsFilters({ ...adsFilters, statusId: e.target.value })}
className="w-full h-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-4 py-3 md:py-0 text-sm text-white outline-none focus:border-blue-500 appearance-none cursor-pointer" className="w-full h-full bg-white/5 border border-white/10 rounded-xl md:rounded-2xl px-4 py-3 md:py-0 text-sm text-white outline-none focus:border-blue-500 appearance-none cursor-pointer"
> >
<option value="" className="bg-gray-900">Todos los Estados</option> <option value="" className="bg-gray-900">Activos y Otros</option>
{Object.entries(STATUS_CONFIG).map(([id, config]) => ( {Object.entries(STATUS_CONFIG)
.filter(([id]) => id !== "9") // Excluimos eliminados de la lista de Avisos
.map(([id, config]) => (
<option key={id} value={id} className="bg-gray-900">{config.label}</option> <option key={id} value={id} className="bg-gray-900">{config.label}</option>
))} ))}
</select> </select>
@@ -370,7 +379,7 @@ export default function AdminPage() {
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{data.ads.map((ad: any, index: number) => { {data.ads.map((ad: any, index: number) => {
return ( return (
<tr key={ad.adID} className="hover:bg-white/5 transition-colors relative" style={{ zIndex: 50 - index }}> <tr key={ad.adID} className={`hover:bg-white/5 transition-colors relative ${ad.statusID === 9 ? 'opacity-60 bg-red-900/5' : ''}`} style={{ zIndex: 50 - index }}>
<td className="px-8 py-5"> <td className="px-8 py-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl border border-white/10" alt="" /> <img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl border border-white/10" alt="" />
@@ -436,7 +445,7 @@ export default function AdminPage() {
<div className="md:hidden space-y-4"> <div className="md:hidden space-y-4">
{data.ads.map((ad: any, index: number) => { {data.ads.map((ad: any, index: number) => {
return ( return (
<div key={ad.adID} className="glass p-5 rounded-3xl border border-white/5 space-y-4 shadow-xl relative" style={{ zIndex: 50 - index }}> <div key={ad.adID} className={`glass p-5 rounded-3xl border space-y-4 shadow-xl relative transition-all ${ad.statusID === 9 ? 'border-red-500/30 bg-red-900/10 opacity-70' : 'border-white/5'}`} style={{ zIndex: 50 - index }}>
<div className="flex gap-4 items-start"> <div className="flex gap-4 items-start">
<img src={getImageUrl(ad.thumbnail)} className="w-24 h-16 object-cover rounded-xl border border-white/10" alt="" /> <img src={getImageUrl(ad.thumbnail)} className="w-24 h-16 object-cover rounded-xl border border-white/10" alt="" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -526,6 +535,152 @@ export default function AdminPage() {
</div> </div>
)} )}
{/* === VISTA PAPELERA === */}
{activeTab === 'trash' && data.ads && (
<div className="space-y-8">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white/5 p-6 rounded-[2rem] border border-red-500/10 backdrop-blur-xl">
<div className="flex flex-col md:flex-row gap-4 w-full">
<div className="relative flex-1 group">
<input
type="text"
placeholder="Buscar en papelera..."
value={adsFilters.q}
onChange={e => setAdsFilters({ ...adsFilters, q: e.target.value })}
onKeyDown={e => e.key === 'Enter' && loadData()}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-12 py-4 text-sm text-white outline-none focus:border-red-500 transition-all focus:bg-white/10"
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
</div>
</div>
<button
onClick={() => { setAdsFilters({ ...adsFilters, page: 1 }); loadData(); }}
className="w-full md:w-auto bg-red-600 hover:bg-red-500 text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95"
>
Filtrar Papelera
</button>
</div>
<div className="hidden md:block glass rounded-[2.5rem] overflow-hidden border border-red-500/10">
<table className="w-full text-left">
<thead className="bg-red-900/10">
<tr>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-red-500">Aviso Eliminado</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500">Eliminado el</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-red-400">Borrado Definitivo</th>
<th className="px-8 py-5 text-xs font-black uppercase tracking-widest text-gray-500 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.ads.map((ad: any) => {
const deleteDate = ad.deletedAt ? parseUTCDate(ad.deletedAt) : null;
const hardDeleteDate = deleteDate ? new Date(deleteDate.getTime() + (60 * 24 * 60 * 60 * 1000)) : null;
return (
<tr key={ad.adID} className="hover:bg-red-900/5 transition-colors">
<td className="px-8 py-5">
<div className="flex items-center gap-4">
<img src={getImageUrl(ad.thumbnail)} className="w-16 h-12 object-cover rounded-lg border border-white/10 grayscale opacity-50" alt="" />
<div>
<span className="text-sm font-black text-gray-300 uppercase block">{ad.brandName} {ad.versionName}</span>
<span className="text-[10px] text-gray-600 font-bold">ID: #{ad.adID} {ad.userName}</span>
</div>
</div>
</td>
<td className="px-8 py-5">
<span className="text-sm text-gray-400 font-medium">
{deleteDate ? deleteDate.toLocaleDateString() : 'N/A'}
</span>
</td>
<td className="px-8 py-5">
<div className="flex flex-col">
<span className="text-sm text-red-400 font-black">
{hardDeleteDate ? hardDeleteDate.toLocaleDateString() : 'N/A'}
</span>
<span className="text-[9px] text-red-400/50 uppercase font-bold italic">Auto-limpieza programada</span>
</div>
</td>
<td className="px-8 py-5 text-right">
<button
onClick={() => initiateStatusChange(ad.adID, AD_STATUSES.DRAFT)}
className="bg-white/5 hover:bg-amber-600/20 text-amber-400 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10 hover:border-amber-500/30 transition-all"
>
Restaurar como Borrador
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Móvil Papelera */}
<div className="md:hidden space-y-4">
{data.ads.map((ad: any) => {
const deleteDate = ad.deletedAt ? parseUTCDate(ad.deletedAt) : null;
const hardDeleteDate = deleteDate ? new Date(deleteDate.getTime() + (60 * 24 * 60 * 60 * 1000)) : null;
return (
<div key={ad.adID} className="glass p-5 rounded-3xl border border-red-500/10 space-y-4 opacity-80">
<div className="flex gap-4">
<img src={getImageUrl(ad.thumbnail)} className="w-20 h-14 object-cover rounded-xl grayscale opacity-50" alt="" />
<div className="flex-1">
<h4 className="text-sm font-black text-white uppercase">{ad.brandName} {ad.versionName}</h4>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest">ID: #{ad.adID}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 py-3 border-y border-white/5">
<div className="flex flex-col">
<span className="text-[8px] font-black text-gray-500 uppercase tracking-widest">Eliminado el</span>
<span className="text-xs text-white">{deleteDate ? deleteDate.toLocaleDateString() : 'N/A'}</span>
</div>
<div className="flex flex-col">
<span className="text-[8px] font-black text-red-400 uppercase tracking-widest">Borrado definitivo</span>
<span className="text-xs text-red-400 font-bold">{hardDeleteDate ? hardDeleteDate.toLocaleDateString() : 'N/A'}</span>
</div>
</div>
<button
onClick={() => initiateStatusChange(ad.adID, AD_STATUSES.DRAFT)}
className="w-full bg-amber-600/10 text-amber-400 py-3 rounded-xl border border-amber-500/20 text-[10px] font-black uppercase tracking-widest"
>
Restaurar como Borrador
</button>
</div>
);
})}
</div>
{data.ads.length === 0 && (
<div className="p-20 text-center glass rounded-[2.5rem] border-dashed border-2 border-white/5">
<p className="text-gray-500 font-bold uppercase tracking-widest">La papelera está vacía.</p>
</div>
)}
{/* Paginación Papelera */}
{data.total > data.pageSize && (
<div className="flex justify-center gap-4 mt-8">
<button
disabled={adsFilters.page === 1}
onClick={() => { const p = adsFilters.page - 1; setAdsFilters({ ...adsFilters, page: p }); loadData(); }}
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
>
</button>
<div className="flex items-center px-6 rounded-xl bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest text-gray-400">
{data.page} / {Math.ceil(data.total / data.pageSize)}
</div>
<button
disabled={adsFilters.page >= Math.ceil(data.total / data.pageSize)}
onClick={() => { const p = adsFilters.page + 1; setAdsFilters({ ...adsFilters, page: p }); loadData(); }}
className="p-4 rounded-xl bg-white/5 border border-white/10 text-white disabled:opacity-20 transition-all active:scale-95"
>
</button>
</div>
)}
</div>
)}
{/* VISTA MODERACIÓN */} {/* VISTA MODERACIÓN */}
{activeTab === 'moderation' && Array.isArray(data) && ( {activeTab === 'moderation' && Array.isArray(data) && (
<div className="space-y-6"> <div className="space-y-6">