feat(F-177): completed feature

This commit is contained in:
chattie
2026-08-22 18:54:41 +02:00
parent 4081462cc4
commit abdb03b6b5
17 changed files with 326 additions and 23 deletions

View File

@@ -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 }) {
<div className="hidden lg:block">
<SidebarToggle expanded={!desktopCollapsed} onToggle={toggleDesktop} mode="desktop" />
</div>
{user.role === 'admin' && <OrderNotifications />}
</header>
<main className="w-full px-4 py-6 sm:px-6 lg:px-10 lg:py-8">{children}</main>
</div>

View File

@@ -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<StaleOrderNotification[]>([]);
const [total, setTotal] = useState(0);
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);
}
}, []);
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 (
<div ref={rootRef} className="relative ml-auto">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-label={`Notificaciones de pedidos${total ? `: ${total} 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 && (
<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}
</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">
<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>
<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>
))}
</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>}
</section>
)}
</div>
);
}

View File

@@ -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 ─────────────────────────────────────────────────────────────────

View File

@@ -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;
`);
};

View File

@@ -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)',

View File

@@ -232,7 +232,12 @@ export class PgOrderRepository implements OrderRepository {
async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView | undefined> {
const result = await this.pool.query<OrderRow>(
`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],
);

View File

@@ -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<{