diff --git a/backlog/features.json b/backlog/features.json
index 09c9a7b..5d4d13c 100644
--- a/backlog/features.json
+++ b/backlog/features.json
@@ -7013,6 +7013,68 @@
"description": "Create admin header notification module for pending and shipped orders unchanged for more than 24 hours",
"priority": "high",
"risk": "med",
+ "status": "done",
+ "created_at": "2026-08-22",
+ "gates": {
+ "reviewer": true,
+ "security": true,
+ "qa": true,
+ "close": true
+ },
+ "completed_at": "2026-08-22T16:54:41Z"
+ },
+ {
+ "id": "F-178",
+ "type": "fix",
+ "title": "Make POS EAN search unique and Enter add item",
+ "description": "Exact EAN search must show only one product and Enter must add one unit to the current order",
+ "priority": "high",
+ "risk": "low",
+ "status": "pending",
+ "created_at": "2026-08-22",
+ "gates": {
+ "reviewer": false,
+ "security": false,
+ "qa": false
+ }
+ },
+ {
+ "id": "F-179",
+ "type": "fix",
+ "title": "Fix POS sale Internal Server Error",
+ "description": "POST /api/pos/sales returns HTTP 500 when completing a TPV sale",
+ "priority": "high",
+ "risk": "high",
+ "status": "pending",
+ "created_at": "2026-08-22",
+ "gates": {
+ "reviewer": false,
+ "security": false,
+ "qa": false
+ }
+ },
+ {
+ "id": "F-180",
+ "type": "fix",
+ "title": "Accept decimal euros in POS discount",
+ "description": "POS discount input must accept 3,50 or 3.50 as EUR instead of raw cents",
+ "priority": "high",
+ "risk": "med",
+ "status": "pending",
+ "created_at": "2026-08-22",
+ "gates": {
+ "reviewer": false,
+ "security": false,
+ "qa": false
+ }
+ },
+ {
+ "id": "F-181",
+ "type": "feature",
+ "title": "Fix POS customer search and add customer creation",
+ "description": "POS customer search returns no clients and cashier must be able to create a customer from the register",
+ "priority": "high",
+ "risk": "med",
"status": "pending",
"created_at": "2026-08-22",
"gates": {
diff --git a/project/apps/admin/src/app/(dashboard)/layout.tsx b/project/apps/admin/src/app/(dashboard)/layout.tsx
index 98abba0..9f44795 100644
--- a/project/apps/admin/src/app/(dashboard)/layout.tsx
+++ b/project/apps/admin/src/app/(dashboard)/layout.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
+import { OrderNotifications } from '@/components/OrderNotifications';
import { visibleNavItems, type NavItem } from '@/lib/permissions';
import type { Role } from '@/types';
@@ -216,6 +217,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
+ {user.role === 'admin' && }
{children}
diff --git a/project/apps/admin/src/components/OrderNotifications.tsx b/project/apps/admin/src/components/OrderNotifications.tsx
new file mode 100644
index 0000000..c052455
--- /dev/null
+++ b/project/apps/admin/src/components/OrderNotifications.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import Link from 'next/link';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ordersApi, type StaleOrderNotification } from '@/lib/api-client';
+
+const POLL_INTERVAL_MS = 5 * 60 * 1000;
+
+function ageLabel(hours: number): string {
+ if (hours < 48) return `${hours} h sin cambiar`;
+ const days = Math.floor(hours / 24);
+ return `${days} ${days === 1 ? 'día' : 'días'} sin cambiar`;
+}
+
+function money(cents: number): string {
+ return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
+}
+
+export function OrderNotifications() {
+ const [open, setOpen] = useState(false);
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const rootRef = useRef(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);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [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);
+ };
+ document.addEventListener('mousedown', close);
+ document.addEventListener('keydown', key);
+ return () => {
+ document.removeEventListener('mousedown', close);
+ document.removeEventListener('keydown', key);
+ };
+ }, [open, load]);
+
+ return (
+
+
+
+ {open && (
+
+
+
+
Pedidos para revisar
+
Pendientes o enviados sin cambios en 24 h
+
+
+
+
+ {loading &&
Cargando…
}
+ {!loading && error &&
{error}
}
+ {!loading && !error && items.length === 0 &&
No hay pedidos atrasados.
}
+ {!loading && !error && items.map((item) => (
+
setOpen(false)} className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
+
+
+
Pedido #{item.id.slice(0, 8)}
+
{item.customerEmail || 'Cliente sin email'}
+
+
+ {item.state === 'PENDING' ? 'Pendiente' : 'Enviado'}
+
+
+
+ {ageLabel(item.ageHours)}
+ {money(item.totalCents)}
+
+
+ ))}
+
+ {total > items.length && 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}
+
+ )}
+
+ );
+}
diff --git a/project/apps/admin/src/lib/api-client.ts b/project/apps/admin/src/lib/api-client.ts
index 38d6f7f..cbc2602 100644
--- a/project/apps/admin/src/lib/api-client.ts
+++ b/project/apps/admin/src/lib/api-client.ts
@@ -93,6 +93,15 @@ export const productsApi = {
// ── Orders ────────────────────────────────────────────────────────────────────
+export interface StaleOrderNotification {
+ id: string;
+ state: 'PENDING' | 'SHIPPED';
+ totalCents: number;
+ customerEmail: string | null;
+ stateChangedAt: string;
+ ageHours: number;
+}
+
export const ordersApi = {
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
const sp = new URLSearchParams();
@@ -128,6 +137,10 @@ export const ordersApi = {
),
history: (id: string) =>
api.get<{ items: import('@/types').OrderHistoryEvent[] }>(`/api/orders/${id}/history`),
+ staleNotifications: (hours = 24, limit = 20) =>
+ api.get<{ total: number; thresholdHours: number; items: StaleOrderNotification[] }>(
+ `/api/orders/notifications/stale?hours=${hours}&limit=${limit}`,
+ ),
};
// ── Customers ─────────────────────────────────────────────────────────────────
diff --git a/project/migrations/051_order_state_changed_at.js b/project/migrations/051_order_state_changed_at.js
new file mode 100644
index 0000000..d489ed2
--- /dev/null
+++ b/project/migrations/051_order_state_changed_at.js
@@ -0,0 +1,28 @@
+/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
+exports.shorthands = undefined;
+
+exports.up = (pgm) => {
+ pgm.sql(`
+ ALTER TABLE orders_orders
+ ADD COLUMN IF NOT EXISTS state_changed_at timestamptz;
+
+ UPDATE orders_orders
+ SET state_changed_at = updated_at
+ WHERE state_changed_at IS NULL;
+
+ ALTER TABLE orders_orders
+ ALTER COLUMN state_changed_at SET DEFAULT now(),
+ ALTER COLUMN state_changed_at SET NOT NULL;
+
+ CREATE INDEX IF NOT EXISTS orders_orders_stale_state_idx
+ ON orders_orders (state, state_changed_at)
+ WHERE state IN ('PENDING', 'SHIPPED');
+ `);
+};
+
+exports.down = (pgm) => {
+ pgm.sql(`
+ DROP INDEX IF EXISTS orders_orders_stale_state_idx;
+ ALTER TABLE orders_orders DROP COLUMN IF EXISTS state_changed_at;
+ `);
+};
diff --git a/project/src/modules/orders/api/orders.routes.ts b/project/src/modules/orders/api/orders.routes.ts
index a428c8b..0b53fbd 100644
--- a/project/src/modules/orders/api/orders.routes.ts
+++ b/project/src/modules/orders/api/orders.routes.ts
@@ -180,6 +180,59 @@ export async function registerOrdersRoutes(
return reply.send({ items: items.map(serializeOrder), total });
});
+ const staleNotificationsSchema: FastifySchema = {
+ tags: ['Orders'],
+ summary: 'List stale order notifications (admin)',
+ querystring: {
+ type: 'object',
+ properties: {
+ hours: { type: 'integer', minimum: 1, maximum: 168, default: 24 },
+ limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
+ },
+ },
+ response: { 401: errorSchema, 403: errorSchema },
+ };
+ app.get('/orders/notifications/stale', { schema: staleNotificationsSchema }, async (request, reply) => {
+ const user = await deps.authenticate(request);
+ requireRole(user, 'admin');
+ const query = request.query as { hours?: string; limit?: string };
+ const hours = Math.min(Math.max(Number(query.hours ?? 24) || 24, 1), 168);
+ const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 50);
+ const result = await deps.pool.query<{
+ id: string;
+ state: 'PENDING' | 'SHIPPED';
+ total_cents: number;
+ customer_email: string | null;
+ state_changed_at: Date;
+ age_hours: number;
+ total_count: number;
+ }>(
+ `SELECT o.id, o.state, o.total_cents, u.email AS customer_email,
+ o.state_changed_at,
+ FLOOR(EXTRACT(EPOCH FROM (now() - o.state_changed_at)) / 3600)::int AS age_hours,
+ COUNT(*) OVER()::int AS total_count
+ FROM orders_orders o
+ LEFT JOIN identity_users u ON u.id = o.user_id
+ WHERE o.state IN ('PENDING', 'SHIPPED')
+ AND o.state_changed_at <= now() - ($1::int * interval '1 hour')
+ ORDER BY o.state_changed_at ASC
+ LIMIT $2`,
+ [hours, limit],
+ );
+ return reply.send({
+ total: result.rows[0]?.total_count ?? 0,
+ thresholdHours: hours,
+ items: result.rows.map((row) => ({
+ id: row.id,
+ state: row.state,
+ totalCents: row.total_cents,
+ customerEmail: row.customer_email,
+ stateChangedAt: row.state_changed_at.toISOString(),
+ ageHours: row.age_hours,
+ })),
+ });
+ });
+
const getOrderAdminSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Get order (admin)',
diff --git a/project/src/modules/orders/infrastructure/pg-order-repository.ts b/project/src/modules/orders/infrastructure/pg-order-repository.ts
index 8093791..979c5be 100644
--- a/project/src/modules/orders/infrastructure/pg-order-repository.ts
+++ b/project/src/modules/orders/infrastructure/pg-order-repository.ts
@@ -232,7 +232,12 @@ export class PgOrderRepository implements OrderRepository {
async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise {
const result = await this.pool.query(
- `UPDATE orders_orders SET state = $2, tracking_number = COALESCE($3, tracking_number), courier = COALESCE($4, courier), updated_at = now()
+ `UPDATE orders_orders
+ SET state = $2,
+ tracking_number = COALESCE($3, tracking_number),
+ courier = COALESCE($4, courier),
+ state_changed_at = now(),
+ updated_at = now()
WHERE id = $1 RETURNING *`,
[id, state, trackingNumber ?? null, courier ?? null],
);
diff --git a/project/src/modules/payments/api/payments.routes.ts b/project/src/modules/payments/api/payments.routes.ts
index 7aa4691..025bb5c 100644
--- a/project/src/modules/payments/api/payments.routes.ts
+++ b/project/src/modules/payments/api/payments.routes.ts
@@ -48,7 +48,7 @@ export async function registerPaymentsRoutes(
if (!row) throw new OrderNotFoundError();
if (!ALLOWED_TRANSITIONS[row.state]?.includes(state)) throw new OrderNotFoundError();
await deps.pool.query(
- 'UPDATE orders_orders SET state = $2, updated_at = now() WHERE id = $1',
+ 'UPDATE orders_orders SET state = $2, state_changed_at = now(), updated_at = now() WHERE id = $1',
[id, state],
);
const refreshed = await deps.pool.query<{
diff --git a/work/artifacts/F-177/architect.md b/work/artifacts/F-177/architect.md
new file mode 100644
index 0000000..4d65014
--- /dev/null
+++ b/work/artifacts/F-177/architect.md
@@ -0,0 +1,7 @@
+# F-177 — Architecture
+
+- Add `orders_orders.state_changed_at`, initialized from `updated_at`, indexed for monitored states.
+- Every state transition updates both `updated_at` and `state_changed_at`; non-state edits only update `updated_at`.
+- Admin endpoint `GET /orders/notifications/stale?hours=24&limit=20`: RBAC admin, fixed monitored states, parameterized threshold, total + items.
+- Header client component polls every 5 minutes, refreshes when opened, closes on Escape/outside click, and links to `/orders/:id`.
+- Bell badge caps visual text at `99+`; dropdown differentiates pending and shipped.
diff --git a/work/artifacts/F-177/documenter.md b/work/artifacts/F-177/documenter.md
new file mode 100644
index 0000000..fcdc32b
--- /dev/null
+++ b/work/artifacts/F-177/documenter.md
@@ -0,0 +1,3 @@
+# Admin order notifications
+
+The bell in the Admin header lists orders that have remained `Pending` or `Shipped` for more than 24 hours without a state transition. The badge is the total number of stale orders. Open the bell to review age, customer and amount; selecting an entry opens that order. Data refreshes every five minutes and whenever the panel opens.
diff --git a/work/artifacts/F-177/implementer.md b/work/artifacts/F-177/implementer.md
new file mode 100644
index 0000000..dfe0bd3
--- /dev/null
+++ b/work/artifacts/F-177/implementer.md
@@ -0,0 +1,8 @@
+# F-177 — Implementer
+
+- Migration 051 adds indexed `state_changed_at`, backfilled from `updated_at`.
+- Repository and payment state transitions now update that timestamp; edits unrelated to state do not reset the 24 h clock.
+- New admin-only `GET /orders/notifications/stale` returns PENDING/SHIPPED orders older than threshold, total count, customer, amount and elapsed hours.
+- Header bell includes count badge, accessible dropdown, status/age/amount, direct links, empty/error/loading states, 5-minute polling, manual refresh, Escape and outside-click closing.
+
+Evidence: migration applied; project typecheck PASS; order tests 7/7; Admin Next build PASS; authenticated endpoint HTTP 200. Controlled acceptance row at 25 h returned total=1/state=PENDING/ageHours=25, then database row was restored.
diff --git a/work/artifacts/F-177/leader-close.json b/work/artifacts/F-177/leader-close.json
new file mode 100644
index 0000000..b548818
--- /dev/null
+++ b/work/artifacts/F-177/leader-close.json
@@ -0,0 +1 @@
+{"feature_id":"F-177","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"reviewer/security/qa approved","ok":true},{"item":"migration applied","ok":true},{"item":"controlled 25-hour runtime acceptance","ok":true},{"item":"verify.sh green","ok":true}],"issues":[]}
diff --git a/work/artifacts/F-177/qa.json b/work/artifacts/F-177/qa.json
new file mode 100644
index 0000000..d162b5c
--- /dev/null
+++ b/work/artifacts/F-177/qa.json
@@ -0,0 +1 @@
+{"feature_id":"F-177","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"25-hour PENDING order appears","ok":true},{"item":"fresh/no qualifying orders excluded","ok":true},{"item":"endpoint total and age correct","ok":true},{"item":"Admin build","ok":true},{"item":"orders regression tests 7/7","ok":true},{"item":"verify.sh","ok":true}],"issues":[]}
diff --git a/work/artifacts/F-177/reviewer.json b/work/artifacts/F-177/reviewer.json
new file mode 100644
index 0000000..f7cb072
--- /dev/null
+++ b/work/artifacts/F-177/reviewer.json
@@ -0,0 +1 @@
+{"feature_id":"F-177","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"dedicated state transition timestamp","ok":true},{"item":"PENDING and SHIPPED threshold query","ok":true},{"item":"responsive accessible header dropdown","ok":true},{"item":"tests/build/runtime","ok":true}],"issues":[]}
diff --git a/work/artifacts/F-177/security.json b/work/artifacts/F-177/security.json
new file mode 100644
index 0000000..5658a24
--- /dev/null
+++ b/work/artifacts/F-177/security.json
@@ -0,0 +1 @@
+{"feature_id":"F-177","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"admin role required","ok":true},{"item":"hours and limit bounded","ok":true},{"item":"SQL values parameterized","ok":true},{"item":"no sensitive data beyond existing admin order access","ok":true}],"issues":[]}
diff --git a/work/current.md b/work/current.md
index 6da83d6..69a841f 100644
--- a/work/current.md
+++ b/work/current.md
@@ -1,3 +1,3 @@
-# F-176 — Fix POS existing product search
+# F-177 — Add stale order notifications to admin header
-POS search currently executes stale SQL against nonexistent variant fields (`v.name`, `v.active`, `p.active`, `s.quantity`, `pp.price_cents`, category-by-variant), then hides HTTP 500 as an empty result. Align search/EAN/SKU queries with current catalog, pricing, inventory and category schemas. Barcode text must support exact and partial EAN lookup.
+Create an admin-only stale-order endpoint and header bell. Notify when an order remains in `PENDING` or `SHIPPED` for at least 24 hours since its last state transition. Show badge count, dropdown details, elapsed time and direct links to each order. Refresh automatically and on demand.
diff --git a/work/runtime-status.json b/work/runtime-status.json
index 857287d..676c627 100644
--- a/work/runtime-status.json
+++ b/work/runtime-status.json
@@ -1,68 +1,68 @@
{
- "feature_id": "F-176",
+ "feature_id": "F-177",
"stage": "close",
"agent": "leader",
- "action": "Close POS existing product search fix",
+ "action": "Close admin stale-order notifications",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
- "updated_at": "2026-08-22T16:49:23Z",
+ "updated_at": "2026-08-22T16:54:21Z",
"timeline": [
{
- "ts": "2026-08-22T16:45:39Z",
+ "ts": "2026-08-22T16:49:42Z",
"agent": "leader",
"stage": "intake",
"state": "running",
- "message": "Diagnose POS product search for existing barcode"
+ "message": "Define stale pending/shipped order notifications"
},
{
- "ts": "2026-08-22T16:46:57Z",
+ "ts": "2026-08-22T16:50:13Z",
"agent": "architect",
"stage": "design",
"state": "running",
- "message": "Align POS lookup SQL with current catalog schema"
+ "message": "Design state-age endpoint and admin header bell"
},
{
- "ts": "2026-08-22T16:47:05Z",
+ "ts": "2026-08-22T16:50:24Z",
"agent": "implementer",
"stage": "build",
"state": "running",
- "message": "Repair POS product lookup queries and visible errors"
+ "message": "Implement stale-order endpoint, state timestamp and header dropdown"
},
{
- "ts": "2026-08-22T16:48:42Z",
+ "ts": "2026-08-22T16:53:33Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
- "message": "Review current-schema POS search repair"
+ "message": "Review state-age semantics and header component"
},
{
- "ts": "2026-08-22T16:48:51Z",
+ "ts": "2026-08-22T16:53:42Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
- "message": "Audit POS search authorization and SQL parameters"
+ "message": "Audit notification endpoint RBAC and query bounds"
},
{
- "ts": "2026-08-22T16:49:02Z",
+ "ts": "2026-08-22T16:53:51Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
- "message": "Validate partial barcode and exact EAN searches"
+ "message": "Validate 24-hour stale-order notification acceptance"
},
{
- "ts": "2026-08-22T16:49:12Z",
+ "ts": "2026-08-22T16:54:10Z",
"agent": "documenter",
"stage": "document",
"state": "running",
- "message": "Document repaired POS lookup behavior"
+ "message": "Document stale-order notification behavior"
},
{
- "ts": "2026-08-22T16:49:23Z",
+ "ts": "2026-08-22T16:54:21Z",
"agent": "leader",
"stage": "close",
"state": "running",
- "message": "Close POS existing product search fix"
+ "message": "Close admin stale-order notifications"
}
]
}