fix: frontend fixes batch - live search proxy, quick products 10 slots, categories emoji list, bell notifications with tabs, awaiting payment orders endpoint
This commit is contained in:
99
project/apps/admin/src/components/BellNotifications.tsx
Normal file
99
project/apps/admin/src/components/BellNotifications.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'pending_orders' | 'stale_shipped' | 'awaiting_payment';
|
||||
count: number;
|
||||
message: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
pending_orders: '📦',
|
||||
stale_shipped: '🚚',
|
||||
awaiting_payment: '💳',
|
||||
};
|
||||
|
||||
export function BellNotifications() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/backoffice/notifications', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { items?: Notification[] };
|
||||
setNotifications(data.items ?? []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); void setInterval(load, 60_000); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const total = notifications.reduce((s, n) => s + n.count, 0);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button type="button" onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-xl transition-colors"
|
||||
title="Notificaciones">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 15.638V17m0 0V21m0-4H9.5a3.5 3.5 0 100-7 3.5 3.5 0 000 7zm0 0H6m6 0a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{total > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
|
||||
{total > 99 ? '99+' : total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-gray-200 rounded-2xl shadow-xl z-50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">Notificaciones</h3>
|
||||
<button onClick={() => void load()} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
🔄 Actualizar
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-400 text-sm">
|
||||
✨ Todo en orden
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<a key={n.id} href={n.href}
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 border-b border-gray-50 last:border-0 transition-colors"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="text-xl shrink-0">{ICONS[n.type]}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-900 font-medium">{n.message}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{n.count} pedido{n.count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100">
|
||||
<a href="/orders" onClick={() => setOpen(false)}
|
||||
className="text-xs text-[#2D6A4F] font-medium hover:underline">
|
||||
Ver todos los pedidos →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,101 +16,185 @@ function money(cents: number): string {
|
||||
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
|
||||
}
|
||||
|
||||
type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string };
|
||||
|
||||
export function OrderNotifications() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<StaleOrderNotification[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [staleItems, setStaleItems] = useState<StaleOrderNotification[]>([]);
|
||||
const [staleTotal, setStaleTotal] = useState(0);
|
||||
const [awaitingItems, setAwaitingItems] = useState<AwaitingPaymentItem[]>([]);
|
||||
const [awaitingTotal, setAwaitingTotal] = useState(0);
|
||||
const [tab, setTab] = useState<'pending' | 'awaiting'>('pending');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('');
|
||||
try {
|
||||
const data = await ordersApi.staleNotifications();
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudieron cargar las notificaciones');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const [staleData, awaitingData] = await Promise.all([
|
||||
ordersApi.staleNotifications(24, 20),
|
||||
ordersApi.awaitingPaymentNotifications(),
|
||||
]);
|
||||
setStaleItems(staleData.items);
|
||||
setStaleTotal(staleData.total);
|
||||
setAwaitingItems(awaitingData.items);
|
||||
setAwaitingTotal(awaitingData.total);
|
||||
} catch { /* silent */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
useEffect(() => { void load(); void setInterval(load, POLL_INTERVAL_MS); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void load();
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||||
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); };
|
||||
document.addEventListener('mousedown', close);
|
||||
document.addEventListener('keydown', key);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
document.removeEventListener('keydown', key);
|
||||
};
|
||||
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); };
|
||||
}, [open, load]);
|
||||
|
||||
const grandTotal = staleTotal + awaitingTotal;
|
||||
const pendingItems = staleItems.filter(i => i.state === 'PENDING');
|
||||
const shippedItems = staleItems.filter(i => i.state === 'SHIPPED');
|
||||
|
||||
const TabPending = () => (
|
||||
<div>
|
||||
{loading ? (
|
||||
<p className="p-6 text-center text-sm text-gray-500">Cargando…</p>
|
||||
) : pendingItems.length === 0 && shippedItems.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-gray-500">No hay pedidos pendientes o atrasados.</p>
|
||||
) : (
|
||||
<>
|
||||
{pendingItems.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-2 text-xs font-semibold text-amber-600 bg-amber-50 border-b border-amber-100">
|
||||
📦 {pendingItems.length} pendientes de procesar
|
||||
</p>
|
||||
{pendingItems.map((item) => (
|
||||
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-800">Pendiente</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-xs">
|
||||
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
||||
<span className="text-gray-600">{money(item.totalCents)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{shippedItems.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-2 text-xs font-semibold text-blue-600 bg-blue-50 border-b border-blue-100">
|
||||
🚚 {shippedItems.length} en tránsito sin actualizar
|
||||
</p>
|
||||
{shippedItems.map((item) => (
|
||||
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-blue-100 px-2 py-1 text-[11px] font-semibold text-blue-800">Enviado</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-xs">
|
||||
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
||||
<span className="text-gray-600">{money(item.totalCents)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TabAwaiting = () => (
|
||||
<div>
|
||||
{loading ? (
|
||||
<p className="p-6 text-center text-sm text-gray-500">Cargando…</p>
|
||||
) : awaitingItems.length === 0 ? (
|
||||
<p className="p-6 text-center text-sm text-gray-500">No hay pagos pendientes.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-4 py-2 text-xs font-semibold text-purple-600 bg-purple-50 border-b border-purple-100">
|
||||
💳 {awaitingItems.length} esperando confirmación de pago
|
||||
</p>
|
||||
{awaitingItems.map((item) => (
|
||||
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)}
|
||||
className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Sin email'}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-purple-100 px-2 py-1 text-[11px] font-semibold text-purple-800">Esperando pago</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">{money(item.totalCents)}</div>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-label={`Notificaciones de pedidos${total ? `: ${total} pendientes` : ''}`}
|
||||
aria-label={`Notificaciones${grandTotal ? `: ${grandTotal} pendientes` : ''}`}
|
||||
aria-expanded={open}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F]"
|
||||
>
|
||||
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.85 23.85 0 0 0 5.454-1.31A8.97 8.97 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.97 8.97 0 0 1-2.312 6.022 23.85 23.85 0 0 0 5.455 1.31m5.714 0a24.26 24.26 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
|
||||
</svg>
|
||||
{total > 0 && (
|
||||
{grandTotal > 0 && (
|
||||
<span className="absolute -right-1.5 -top-1.5 min-w-5 rounded-full bg-[#E76F51] px-1 text-center text-[11px] font-bold leading-5 text-white">
|
||||
{total > 99 ? '99+' : total}
|
||||
{grandTotal > 99 ? '99+' : grandTotal}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<section aria-label="Pedidos sin cambios durante más de 24 horas" className="absolute right-0 top-12 z-50 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<section aria-label="Notificaciones de pedidos" className="absolute right-0 top-12 z-50 w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900">Pedidos para revisar</h2>
|
||||
<p className="text-xs text-gray-500">Pendientes o enviados sin cambios en 24 h</p>
|
||||
</div>
|
||||
<h2 className="font-semibold text-gray-900">Notificaciones</h2>
|
||||
<button type="button" onClick={() => void load()} className="text-xs font-medium text-[#2D6A4F] hover:underline">Actualizar</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading && <p className="p-6 text-center text-sm text-gray-500">Cargando…</p>}
|
||||
{!loading && error && <p className="p-6 text-center text-sm text-red-600">{error}</p>}
|
||||
{!loading && !error && items.length === 0 && <p className="p-6 text-center text-sm text-gray-500">No hay pedidos atrasados.</p>}
|
||||
{!loading && !error && items.map((item) => (
|
||||
<Link key={item.id} href={`/orders/${item.id}`} onClick={() => setOpen(false)} className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900">Pedido #{item.id.slice(0, 8)}</p>
|
||||
<p className="truncate text-xs text-gray-500">{item.customerEmail || 'Cliente sin email'}</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-2 py-1 text-[11px] font-semibold ${item.state === 'PENDING' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'}`}>
|
||||
{item.state === 'PENDING' ? 'Pendiente' : 'Enviado'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-xs">
|
||||
<span className="font-medium text-[#E76F51]">{ageLabel(item.ageHours)}</span>
|
||||
<span className="text-gray-600">{money(item.totalCents)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-100">
|
||||
<button type="button" onClick={() => setTab('pending')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === 'pending' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
📦 Pendientes {staleTotal > 0 && <span className="ml-1 rounded-full bg-amber-100 text-amber-700 px-1.5 py-0.5 text-[10px] font-bold">{staleTotal}</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab('awaiting')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === 'awaiting' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
💳 Esperando pago {awaitingTotal > 0 && <span className="ml-1 rounded-full bg-purple-100 text-purple-700 px-1.5 py-0.5 text-[10px] font-bold">{awaitingTotal}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{total > items.length && <Link href="/orders" onClick={() => setOpen(false)} className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">Ver los {total} pedidos</Link>}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{tab === 'pending' ? <TabPending /> : <TabAwaiting />}
|
||||
</div>
|
||||
{grandTotal > 0 && (
|
||||
<Link href="/orders" onClick={() => setOpen(false)}
|
||||
className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">
|
||||
Ver todos los pedidos →
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user