feat(notif-inventory-alerts): admin bell: add inventory alerts (low stock, out of stock, expired/expiring, low margin)

This commit is contained in:
Deploy
2026-08-27 23:26:01 +02:00
parent 642272e155
commit 1376d3e4f0
14 changed files with 1277 additions and 27 deletions

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ordersApi, type StaleOrderNotification } from '@/lib/api-client';
import { ordersApi, inventoryApi, type StaleOrderNotification } from '@/lib/api-client';
const POLL_INTERVAL_MS = 5 * 60 * 1000;
@@ -18,26 +18,39 @@ function money(cents: number): string {
type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string };
type InventoryNotificationItem = {
id: 'low_stock' | 'out_of_stock' | 'expired' | 'expiring_soon' | 'low_margin';
count: number;
href: string;
label: string;
tone: 'amber' | 'red';
};
export function OrderNotifications() {
const [open, setOpen] = useState(false);
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 [inventoryItems, setInventoryItems] = useState<InventoryNotificationItem[]>([]);
const [inventoryTotal, setInventoryTotal] = useState(0);
const [tab, setTab] = useState<'pending' | 'awaiting' | 'inventory'>('pending');
const [loading, setLoading] = useState(true);
const rootRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
try {
const [staleData, awaitingData] = await Promise.all([
const [staleData, awaitingData, inventoryData] = await Promise.all([
ordersApi.staleNotifications(24, 20),
ordersApi.awaitingPaymentNotifications(),
inventoryApi.notificationCounts(),
]);
setStaleItems(staleData.items);
setStaleTotal(staleData.total);
setAwaitingItems(awaitingData.items);
setAwaitingTotal(awaitingData.total);
setInventoryItems(inventoryData.items);
setInventoryTotal(inventoryData.total);
} catch { /* silent */ }
finally { setLoading(false); }
}, []);
@@ -54,7 +67,7 @@ export function OrderNotifications() {
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); };
}, [open, load]);
const grandTotal = staleTotal + awaitingTotal;
const grandTotal = staleTotal + awaitingTotal + inventoryTotal;
const pendingItems = staleItems.filter(i => i.state === 'PENDING');
const shippedItems = staleItems.filter(i => i.state === 'SHIPPED');
@@ -146,6 +159,56 @@ export function OrderNotifications() {
</div>
);
const TabInventory = () => (
<div>
{loading ? (
<p className="p-6 text-center text-sm text-gray-500">Cargando</p>
) : inventoryItems.length === 0 ? (
<p className="p-6 text-center text-sm text-gray-500">
Sin alertas de inventario
</p>
) : (
<>
<p className="px-4 py-2 text-xs font-semibold text-emerald-600 bg-emerald-50 border-b border-emerald-100">
🏷 {inventoryItems.length} alerta{inventoryItems.length !== 1 ? 's' : ''} operativa{inventoryItems.length !== 1 ? 's' : ''}
</p>
{inventoryItems.map((item) => {
const toneClasses = item.tone === 'red'
? 'bg-red-100 text-red-800'
: 'bg-amber-100 text-amber-800';
const headerClasses = item.tone === 'red'
? 'text-red-600 bg-red-50 border-red-100'
: 'text-amber-600 bg-amber-50 border-amber-100';
const icon = item.id === 'low_stock' ? '⚠️'
: item.id === 'out_of_stock' ? '🚫'
: item.id === 'expired' ? '🗓️'
: item.id === 'expiring_soon' ? '⏳'
: '📉';
return (
<Link key={item.id} href={item.href} 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">
{icon} {item.label}
</p>
<p className="mt-0.5 text-xs text-gray-500">{item.href.replace(/^\//, '')}</p>
</div>
<span className={`shrink-0 rounded-full px-2 py-1 text-[11px] font-semibold ${toneClasses}`}>
{item.count}
</span>
</div>
<p className={`mt-2 inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${headerClasses}`}>
{item.id}
</p>
</Link>
);
})}
</>
)}
</div>
);
return (
<div ref={rootRef} className="relative ml-auto">
<button
@@ -185,9 +248,15 @@ export function OrderNotifications() {
}`}>
💳 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>
<button type="button" onClick={() => setTab('inventory')}
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors ${
tab === 'inventory' ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>
🏷 Inventario {inventoryTotal > 0 && <span className="ml-1 rounded-full bg-emerald-100 text-emerald-700 px-1.5 py-0.5 text-[10px] font-bold">{inventoryTotal}</span>}
</button>
</div>
<div className="max-h-80 overflow-y-auto">
{tab === 'pending' ? <TabPending /> : <TabAwaiting />}
{tab === 'pending' ? <TabPending /> : tab === 'awaiting' ? <TabAwaiting /> : <TabInventory />}
</div>
{grandTotal > 0 && (
<Link href="/orders" onClick={() => setOpen(false)}

View File

@@ -242,6 +242,17 @@ export const inventoryApi = {
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
setStock: (id: string, quantity: number) =>
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
notificationCounts: () =>
api.get<{
items: Array<{
id: 'low_stock' | 'out_of_stock' | 'expired' | 'expiring_soon' | 'low_margin';
count: number;
href: string;
label: string;
tone: 'amber' | 'red';
}>;
total: number;
}>('/api/inventory/admin/notifications'),
};
// ── Pricing ───────────────────────────────────────────────────────────────────