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:
@@ -63,7 +63,7 @@ export const STATUS_CONFIG: Record<number, { label: string; color: string; bg: s
|
||||
icon: '📝'
|
||||
},
|
||||
[AD_STATUSES.DELETED]: {
|
||||
label: 'Eliminar',
|
||||
label: 'Eliminado',
|
||||
color: 'text-white',
|
||||
bg: 'bg-red-700/90',
|
||||
border: 'border-red-500/50',
|
||||
|
||||
@@ -9,7 +9,7 @@ import AdDetailsModal from '../components/AdDetailsModal';
|
||||
import { Link } from 'react-router-dom';
|
||||
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() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('stats');
|
||||
@@ -45,8 +45,8 @@ export default function AdminPage() {
|
||||
let isDanger = false;
|
||||
|
||||
if (newStatus === AD_STATUSES.DELETED) {
|
||||
title = "¿Eliminar Aviso?";
|
||||
message = "Esta acción eliminará el aviso permanentemente. No se puede deshacer.\n\n¿Estás seguro de continuar?";
|
||||
title = "¿Mover a la Papelera?";
|
||||
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;
|
||||
} else if (newStatus === AD_STATUSES.PAUSED) {
|
||||
title = "Pausar Publicación";
|
||||
@@ -179,6 +179,13 @@ export default function AdminPage() {
|
||||
page: adsFilters.page
|
||||
});
|
||||
break;
|
||||
case 'trash':
|
||||
res = await AdminService.getAllAds({
|
||||
q: adsFilters.q,
|
||||
statusId: 9, // Forzamos 9 para papelera
|
||||
page: adsFilters.page
|
||||
});
|
||||
break;
|
||||
}
|
||||
setData(res);
|
||||
} 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'}`}
|
||||
>
|
||||
<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 className={`transition-transform duration-300 ${isMobileMenuOpen ? 'rotate-180 text-blue-400' : 'text-gray-500'}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{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">
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit'] as TabType[]).map(tab => (
|
||||
{(['stats', 'ads', 'moderation', 'transactions', 'users', 'audit', 'trash'] as TabType[]).map(tab => (
|
||||
<button
|
||||
key={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'}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -244,13 +251,13 @@ export default function AdminPage() {
|
||||
|
||||
{/* Menú tradicional para Escritorio */}
|
||||
<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
|
||||
key={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'}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
@@ -341,10 +348,12 @@ export default function AdminPage() {
|
||||
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"
|
||||
>
|
||||
<option value="" className="bg-gray-900">Todos los Estados</option>
|
||||
{Object.entries(STATUS_CONFIG).map(([id, config]) => (
|
||||
<option key={id} value={id} className="bg-gray-900">{config.label}</option>
|
||||
))}
|
||||
<option value="" className="bg-gray-900">Activos y Otros</option>
|
||||
{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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,7 +379,7 @@ export default function AdminPage() {
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.ads.map((ad: any, index: number) => {
|
||||
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">
|
||||
<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="" />
|
||||
@@ -436,7 +445,7 @@ export default function AdminPage() {
|
||||
<div className="md:hidden space-y-4">
|
||||
{data.ads.map((ad: any, index: number) => {
|
||||
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">
|
||||
<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">
|
||||
@@ -526,6 +535,152 @@ export default function AdminPage() {
|
||||
</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 */}
|
||||
{activeTab === 'moderation' && Array.isArray(data) && (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user