feat(notif-inventory-alerts): admin bell: add inventory alerts (low stock, out of stock, expired/expiring, low margin)
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -75,6 +75,7 @@ const inventoryOverviewFilterSchema = z.enum([
|
||||
'in_stock',
|
||||
'low_stock',
|
||||
'out_of_stock',
|
||||
'expired',
|
||||
'expiring',
|
||||
'low_margin',
|
||||
]);
|
||||
@@ -536,6 +537,92 @@ export async function registerInventoryRoutes(
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /inventory/admin/notifications
|
||||
// Counts of low_stock, out_of_stock, expired, expiring (≤30d), low_margin
|
||||
// for the bell notification badge in admin header. Uses the same CTE as
|
||||
// /inventory/admin/overview to guarantee consistency between bell counts
|
||||
// and the filtered inventory page.
|
||||
const inventoryNotificationsSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Inventory notification counts for admin bell',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/inventory/admin/notifications', { schema: inventoryNotificationsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
|
||||
const countsSql = `
|
||||
WITH raw AS (
|
||||
SELECT
|
||||
p.id AS product_id,
|
||||
p.expiration_date,
|
||||
COALESCE(stock.available, 0) AS available_quantity,
|
||||
price.net_unit_amount_cents,
|
||||
price.offer_cents,
|
||||
price.cost_cents,
|
||||
price.vat_rate,
|
||||
COALESCE(
|
||||
price.offer_cents,
|
||||
ROUND(
|
||||
price.net_unit_amount_cents * CASE price.vat_rate
|
||||
WHEN 'general' THEN 1.21
|
||||
WHEN 'reduced' THEN 1.10
|
||||
WHEN 'super-reduced' THEN 1.04
|
||||
ELSE 1.21
|
||||
END
|
||||
)::int
|
||||
) AS gross_unit_amount_cents
|
||||
FROM catalog_product_variants v
|
||||
JOIN catalog_products p ON p.id = v.product_id
|
||||
LEFT JOIN inventory_stock stock ON stock.variant_id = v.id AND stock.store_id = $1
|
||||
LEFT JOIN pricing_variant_prices price ON price.variant_id = v.id AND price.currency = 'EUR'
|
||||
WHERE p.state = 'active'
|
||||
),
|
||||
base AS (
|
||||
SELECT
|
||||
expiration_date,
|
||||
available_quantity,
|
||||
CASE
|
||||
WHEN cost_cents IS NULL OR gross_unit_amount_cents IS NULL OR gross_unit_amount_cents <= 0 THEN NULL
|
||||
ELSE ROUND((((gross_unit_amount_cents - cost_cents)::numeric / gross_unit_amount_cents::numeric) * 100))::int
|
||||
END AS margin_percent
|
||||
FROM raw
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE available_quantity > 0 AND available_quantity < 5)::int AS low_stock,
|
||||
COUNT(*) FILTER (WHERE available_quantity = 0)::int AS out_of_stock,
|
||||
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date < current_date)::int AS expired,
|
||||
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date >= current_date AND expiration_date <= current_date + 30)::int AS expiring_soon,
|
||||
COUNT(*) FILTER (WHERE margin_percent IS NOT NULL AND margin_percent < 30)::int AS low_margin
|
||||
FROM base
|
||||
`;
|
||||
|
||||
const result = await deps.pool.query<{
|
||||
low_stock: number;
|
||||
out_of_stock: number;
|
||||
expired: number;
|
||||
expiring_soon: number;
|
||||
low_margin: number;
|
||||
}>(countsSql, [DEFAULT_STORE_ID]);
|
||||
|
||||
const r = result.rows[0] ?? {
|
||||
low_stock: 0, out_of_stock: 0, expired: 0, expiring_soon: 0, low_margin: 0,
|
||||
};
|
||||
|
||||
const candidates: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }> = [
|
||||
{ id: 'low_stock', count: Number(r.low_stock ?? 0), href: '/inventory?filter=low_stock', label: 'Stock bajo', tone: 'amber' },
|
||||
{ id: 'out_of_stock', count: Number(r.out_of_stock ?? 0), href: '/inventory?filter=out_of_stock', label: 'Sin stock', tone: 'red' },
|
||||
{ id: 'expired', count: Number(r.expired ?? 0), href: '/inventory?filter=expired', label: 'Caducados', tone: 'red' },
|
||||
{ id: 'expiring_soon', count: Number(r.expiring_soon ?? 0), href: '/inventory?filter=expiring', label: 'Por caducar (≤30d)', tone: 'amber' },
|
||||
{ id: 'low_margin', count: Number(r.low_margin ?? 0), href: '/inventory?filter=low_margin', label: 'Margen <30%', tone: 'red' },
|
||||
];
|
||||
|
||||
const items = candidates.filter((c) => c.count > 0);
|
||||
const total = items.reduce((sum, c) => sum + c.count, 0);
|
||||
|
||||
return reply.send({ items, total });
|
||||
});
|
||||
}
|
||||
|
||||
function mapInventoryError(error: unknown): Error {
|
||||
@@ -570,6 +657,9 @@ function inventoryOverviewFilterClause(
|
||||
if (filter === 'in_stock') return 'available_quantity >= 5';
|
||||
if (filter === 'low_stock') return 'available_quantity > 0 AND available_quantity < 5';
|
||||
if (filter === 'out_of_stock') return 'available_quantity = 0';
|
||||
if (filter === 'expired') {
|
||||
return 'expiration_date IS NOT NULL AND expiration_date < current_date';
|
||||
}
|
||||
if (filter === 'expiring') {
|
||||
return "expiration_date IS NOT NULL AND expiration_date <= current_date + 30";
|
||||
}
|
||||
@@ -578,6 +668,9 @@ function inventoryOverviewFilterClause(
|
||||
}
|
||||
|
||||
function inventoryOverviewOrderBy(filter: z.infer<typeof inventoryOverviewFilterSchema>): string {
|
||||
if (filter === 'expired') {
|
||||
return 'expiration_date DESC NULLS LAST, product_name ASC, sku ASC';
|
||||
}
|
||||
if (filter === 'expiring') {
|
||||
return 'expiration_date ASC NULLS LAST, product_name ASC, sku ASC';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type pg from 'pg';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { AppError, errorEnvelope } from '../../../shared/errors.js';
|
||||
import { registerInventoryRoutes } from '../api/inventory.routes.js';
|
||||
import type { InventoryRoutesDeps } from '../api/inventory.routes.js';
|
||||
|
||||
const created: FastifyInstance[] = [];
|
||||
|
||||
const ADMIN = { id: 'a1', email: 'ana@example.com', role: 'admin' };
|
||||
const CUSTOMER = { id: 'c1', email: 'c1@example.com', role: 'customer' };
|
||||
|
||||
/**
|
||||
* Minimal app: inventory routes + mocked authenticator (no DB).
|
||||
* Mirrors build-app.ts error mapping so AppError(403) surfaces with real
|
||||
* status code.
|
||||
*/
|
||||
async function buildApp(authenticatedUser: unknown, mockRows: Record<string, number> = {}) {
|
||||
const app = Fastify();
|
||||
created.push(app);
|
||||
app.setSerializerCompiler(() => (payload: unknown) => JSON.stringify(payload));
|
||||
app.setErrorHandler(async (err, _request, reply) => {
|
||||
if (err instanceof AppError) {
|
||||
return reply
|
||||
.code(err.statusCode)
|
||||
.send(errorEnvelope(err.statusCode, err.code, err.message, 'test-request-id', err.details));
|
||||
}
|
||||
return reply
|
||||
.code(500)
|
||||
.send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id'));
|
||||
});
|
||||
const mockPool = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
low_stock: mockRows.low_stock ?? 0,
|
||||
out_of_stock: mockRows.out_of_stock ?? 0,
|
||||
expired: mockRows.expired ?? 0,
|
||||
expiring_soon: mockRows.expiring_soon ?? 0,
|
||||
low_margin: mockRows.low_margin ?? 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as unknown as pg.Pool;
|
||||
const deps = {
|
||||
authenticate: vi.fn().mockResolvedValue(authenticatedUser),
|
||||
pool: mockPool,
|
||||
} as unknown as InventoryRoutesDeps;
|
||||
await registerInventoryRoutes(app, deps);
|
||||
await app.ready();
|
||||
return { app };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const app of created) {
|
||||
try {
|
||||
await app.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
created.length = 0;
|
||||
});
|
||||
|
||||
describe('GET /inventory/admin/notifications', () => {
|
||||
it('admin receives items with count > 0 and correct total (AC1, AC2)', async () => {
|
||||
const { app } = await buildApp(ADMIN, {
|
||||
low_stock: 3,
|
||||
out_of_stock: 2,
|
||||
expired: 1,
|
||||
expiring_soon: 5,
|
||||
low_margin: 4,
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
items: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }>;
|
||||
total: number;
|
||||
};
|
||||
expect(body.total).toBe(15);
|
||||
expect(body.items.map((i) => i.id)).toEqual([
|
||||
'low_stock',
|
||||
'out_of_stock',
|
||||
'expired',
|
||||
'expiring_soon',
|
||||
'low_margin',
|
||||
]);
|
||||
expect(body.items[0]).toMatchObject({
|
||||
id: 'low_stock',
|
||||
count: 3,
|
||||
href: '/inventory?filter=low_stock',
|
||||
label: 'Stock bajo',
|
||||
tone: 'amber',
|
||||
});
|
||||
expect(body.items[2]).toMatchObject({
|
||||
id: 'expired',
|
||||
count: 1,
|
||||
href: '/inventory?filter=expired',
|
||||
label: 'Caducados',
|
||||
tone: 'red',
|
||||
});
|
||||
expect(body.items[4]).toMatchObject({
|
||||
id: 'low_margin',
|
||||
count: 4,
|
||||
href: '/inventory?filter=low_margin',
|
||||
label: 'Margen <30%',
|
||||
tone: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits items with count = 0', async () => {
|
||||
const { app } = await buildApp(ADMIN, { low_stock: 0, expired: 2 });
|
||||
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { items: Array<{ id: string; count: number }>; total: number };
|
||||
expect(body.items.map((i) => i.id)).toEqual(['expired']);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it('returns empty items and total 0 when no alerts', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as { items: unknown[]; total: number };
|
||||
expect(body.items).toEqual([]);
|
||||
expect(body.total).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects non-admin with HTTP 403', async () => {
|
||||
const { app } = await buildApp(CUSTOMER);
|
||||
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('expiring_soon uses /inventory?filter=expiring (not a separate filter)', async () => {
|
||||
const { app } = await buildApp(ADMIN, { expiring_soon: 7 });
|
||||
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
|
||||
const body = res.json() as { items: Array<{ id: string; href: string }> };
|
||||
const expiring = body.items.find((i) => i.id === 'expiring_soon');
|
||||
expect(expiring?.href).toBe('/inventory?filter=expiring');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user