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" } ] }