diff --git a/project/apps/admin/src/app/(dashboard)/categories/page.tsx b/project/apps/admin/src/app/(dashboard)/categories/page.tsx index 9a2d8e4..cbf442d 100644 --- a/project/apps/admin/src/app/(dashboard)/categories/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/categories/page.tsx @@ -91,11 +91,41 @@ const EMPTY_FORM: FormState = { }; const EMOJI_OPTIONS = [ - 'πŸ₯¬', 'πŸ₯•', '🍎', 'πŸ₯‘', '🍞', 'πŸ§€', 'πŸ₯›', '🍯', '🌾', 'πŸ₯œ', - '🍡', 'β˜•', '🍫', '🧴', '🧼', '🌿', '🌱', '🌸', '🐝', 'πŸ‡', - '🧺', '♻️', '🏺', '🍢', 'πŸ₯£', '🧊', 'πŸ‡', 'πŸ”', '🐟', '🍊', + // Frutas y verduras + 'πŸ₯¬', 'πŸ₯¦', 'πŸ₯•', 'πŸ†', '🌢️', 'πŸ₯‘', 'πŸ₯’', 'πŸ«‘', 'πŸ§…', 'πŸ§„', + 'πŸ…', 'πŸ₯”', 'πŸ₯', '🍞', 'πŸ₯–', 'πŸ«“', 'πŸ₯—', 'πŸ₯™', '🌽', '🍠', + // Frutas + '🍎', '🍏', '🍐', '🍊', 'πŸ‹', '🍌', 'πŸ‰', 'πŸ‡', 'πŸ“', '🫐', + 'πŸ’', 'πŸ‘', 'πŸ₯­', '🍍', 'πŸ₯₯', 'πŸ₯', 'πŸ«’', 'πŸ…', 'πŸ₯‘', '🍏', + // LΓ‘cteos y huevos + 'πŸ₯›', 'πŸ§€', '🍢', 'πŸ₯›', '🧈', 'πŸ§€', 'πŸ₯š', '🍳', 'πŸ§€', '🧁', + // Aceites y condimentos + 'πŸ«’', 'πŸ«™', 'πŸ§‚', '🌢️', 'πŸ§„', '🍯', 'πŸ«•', 'πŸ§‚', 'πŸ₯«', 'πŸ«—', + // Infusiones y bebidas + '🍡', 'β˜•', 'πŸ«–', '🍺', '🍷', 'πŸ₯‚', '🍾', 'πŸ§ƒ', 'πŸ₯€', 'πŸ§‹', + // Especias y semillas + '🌿', '🌱', '🌾', 'πŸ₯œ', '🌰', '🫘', '🫘', '🫘', 'πŸ„', '🦴', + // EcolΓ³gico y naturaleza + '🌸', '🌻', '🌺', '🌹', 'πŸͺ»', '🌾', '🌡', '🌲', 'πŸͺ¨', 'πŸƒ', + // Animales y eco + '🐝', 'πŸ‡', 'πŸ¦†', 'πŸ”', '🐟', '🦐', '🐚', 'πŸ¦€', '🍯', '🐝', + // Diversos alimentaciΓ³n + '🍫', '🍬', '🍭', '🍦', '🧁', '🍩', 'πŸͺ', 'πŸŽ‚', '🍰', 'πŸ₯§', + 'πŸ§‡', '🧈', 'πŸ₯“', 'πŸ₯©', 'πŸ–', '🌯', 'πŸ₯™', 'πŸ₯—', '🍜', '🍝', + 'πŸ•', 'πŸ₯ͺ', 'πŸ₯', 'πŸ«“', 'πŸ₯―', 'πŸ§‡', 'πŸ₯ž', 'πŸ₯ž', '🍳', 'πŸ§ƒ', ]; +// CategorΓ­as populares predefinidas +const CATEGORY_EMOJI_SUGGESTIONS = [ + 'πŸ₯¬ Hortalizas', '🍎 Frutas', 'πŸ§€ LΓ‘cteos', '🍯 Miel y Derivatives', + '🌿 Hierbas aromΓ‘ticas', 'πŸ«’ Aceites', 'β˜• CafΓ© e infusiones', + '🍞 PanaderΓ­a', 'πŸ₯œ Frutos secos', '🍫 Chocolate', + '🧴 CosmΓ©tica natural', '🌱 Semillas y germinados', 'πŸ„ Setas', + 'πŸ₯¦ Colesterol y salud', '🧺 Compra a granel', '♻️ Cero residuos', +]; + +const EMOJI_CODE_HINT = 'U+1F34E (🍎) β€” busca en emojipedia.org'; + const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [ { bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' }, { bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' }, @@ -376,7 +406,7 @@ export default function CategoriesPage() { className="text-xs text-gray-400 hover:text-red-500">Quitar )} -
+
{EMOJI_OPTIONS.map((emoji) => ( ))}
-

Emoji identificativo que aparecerΓ‘ en las tarjetas de categorΓ­a.

+
+ setForm((f) => ({ ...f, emoji: e.target.value.slice(0, 8) }))} + placeholder="πŸ˜€ o U+1F600" + maxLength={8} + className="flex-1 px-3 py-2 border border-gray-300 rounded-xl text-sm text-2xl text-center" + title={EMOJI_CODE_HINT} + /> + + Escribe emoji o U+XXXX + +
+

Emoji identificativo que aparecerΓ‘ en las tarjetas de categorΓ­a. Escribe o pega cualquier emoji.

diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index 3a024da..01e0391 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -93,7 +93,7 @@ export default function PosAdminPage() { const [touchEnabled, setTouchEnabled] = useState(true); const [discountsEnabled, setDiscountsEnabled] = useState(true); const [quickSlots, setQuickSlots] = useState>( - Array.from({ length: 8 }, () => null), + Array.from({ length: 10 }, () => null), ); const [savingTouch, setSavingTouch] = useState(false); const [touchMessage, setTouchMessage] = useState(''); @@ -197,7 +197,7 @@ export default function PosAdminPage() { setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false); const configured = terminal.settings?.quickProductVariantIds ?? []; - setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null)); + setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null)); setTouchMessage(''); setSelfpayMode(terminal.settings?.selfpayMode ?? false); setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false); diff --git a/project/apps/admin/src/components/BellNotifications.tsx b/project/apps/admin/src/components/BellNotifications.tsx new file mode 100644 index 0000000..240cd54 --- /dev/null +++ b/project/apps/admin/src/components/BellNotifications.tsx @@ -0,0 +1,99 @@ +'use client'; +import { useState, useEffect, useRef } from 'react'; + +interface Notification { + id: string; + type: 'pending_orders' | 'stale_shipped' | 'awaiting_payment'; + count: number; + message: string; + href: string; +} + +const ICONS: Record = { + pending_orders: 'πŸ“¦', + stale_shipped: '🚚', + awaiting_payment: 'πŸ’³', +}; + +export function BellNotifications() { + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const load = async () => { + try { + const res = await fetch('/api/backoffice/notifications', { credentials: 'include' }); + if (!res.ok) return; + const data = await res.json() as { items?: Notification[] }; + setNotifications(data.items ?? []); + } catch { /* silent */ } + }; + + useEffect(() => { void load(); void setInterval(load, 60_000); }, []); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const total = notifications.reduce((s, n) => s + n.count, 0); + + return ( +
+ + + {open && ( +
+
+

Notificaciones

+ +
+
+ {notifications.length === 0 ? ( +
+ ✨ Todo en orden +
+ ) : ( + notifications.map((n) => ( + setOpen(false)}> + {ICONS[n.type]} +
+

{n.message}

+

{n.count} pedido{n.count !== 1 ? 's' : ''}

+
+
+ )) + )} +
+ {total > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/project/apps/admin/src/components/OrderNotifications.tsx b/project/apps/admin/src/components/OrderNotifications.tsx index c052455..20511cd 100644 --- a/project/apps/admin/src/components/OrderNotifications.tsx +++ b/project/apps/admin/src/components/OrderNotifications.tsx @@ -16,101 +16,185 @@ function money(cents: number): string { return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100); } +type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string }; + export function OrderNotifications() { const [open, setOpen] = useState(false); - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); + const [staleItems, setStaleItems] = useState([]); + const [staleTotal, setStaleTotal] = useState(0); + const [awaitingItems, setAwaitingItems] = useState([]); + const [awaitingTotal, setAwaitingTotal] = useState(0); + const [tab, setTab] = useState<'pending' | 'awaiting'>('pending'); 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); - } + const [staleData, awaitingData] = await Promise.all([ + ordersApi.staleNotifications(24, 20), + ordersApi.awaitingPaymentNotifications(), + ]); + setStaleItems(staleData.items); + setStaleTotal(staleData.total); + setAwaitingItems(awaitingData.items); + setAwaitingTotal(awaitingData.total); + } catch { /* silent */ } + finally { setLoading(false); } }, []); - useEffect(() => { - void load(); - const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); - return () => window.clearInterval(timer); - }, [load]); + useEffect(() => { void load(); void setInterval(load, POLL_INTERVAL_MS); }, [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); - }; + 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); - }; + return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); }; }, [open, load]); + const grandTotal = staleTotal + awaitingTotal; + const pendingItems = staleItems.filter(i => i.state === 'PENDING'); + const shippedItems = staleItems.filter(i => i.state === 'SHIPPED'); + + const TabPending = () => ( +
+ {loading ? ( +

Cargando…

+ ) : pendingItems.length === 0 && shippedItems.length === 0 ? ( +

No hay pedidos pendientes o atrasados.

+ ) : ( + <> + {pendingItems.length > 0 && ( +
+

+ πŸ“¦ {pendingItems.length} pendientes de procesar +

+ {pendingItems.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 || 'Sin email'}

+
+ Pendiente +
+
+ {ageLabel(item.ageHours)} + {money(item.totalCents)} +
+ + ))} +
+ )} + {shippedItems.length > 0 && ( +
+

+ 🚚 {shippedItems.length} en trÑnsito sin actualizar +

+ {shippedItems.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 || 'Sin email'}

+
+ Enviado +
+
+ {ageLabel(item.ageHours)} + {money(item.totalCents)} +
+ + ))} +
+ )} + + )} +
+ ); + + const TabAwaiting = () => ( +
+ {loading ? ( +

Cargando…

+ ) : awaitingItems.length === 0 ? ( +

No hay pagos pendientes.

+ ) : ( + <> +

+ πŸ’³ {awaitingItems.length} esperando confirmaciΓ³n de pago +

+ {awaitingItems.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 || 'Sin email'}

+
+ Esperando pago +
+
{money(item.totalCents)}
+ + ))} + + )} +
+ ); + return (
{open && ( -
+
-
-

Pedidos para revisar

-

Pendientes o enviados sin cambios en 24 h

-
+

Notificaciones

-
- {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)} -
- - ))} + {/* Tabs */} +
+ +
- {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} +
+ {tab === 'pending' ? : } +
+ {grandTotal > 0 && ( + 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 todos los pedidos β†’ + + )}
)}
diff --git a/project/apps/admin/src/lib/api-client.ts b/project/apps/admin/src/lib/api-client.ts index 9c1f8b3..f3b1e68 100644 --- a/project/apps/admin/src/lib/api-client.ts +++ b/project/apps/admin/src/lib/api-client.ts @@ -142,6 +142,10 @@ export const ordersApi = { api.get<{ total: number; thresholdHours: number; items: StaleOrderNotification[] }>( `/api/orders/notifications/stale?hours=${hours}&limit=${limit}`, ), + awaitingPaymentNotifications: () => + api.get<{ total: number; items: Array<{ id: string; totalCents: number; customerEmail: string | null; createdAt: string }> }>( + '/api/orders/notifications/awaiting-payment', + ), }; // ── Customers ───────────────────────────────────────────────────────────────── diff --git a/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..c3e982a Binary files /dev/null and b/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..6a6122c Binary files /dev/null and b/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..6b57d90 Binary files /dev/null and b/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts b/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts new file mode 100644 index 0000000..725897a --- /dev/null +++ b/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000'; + +const HANDLERS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; + +export async function GET(req: NextRequest) { return handle(req); } +export async function POST(req: NextRequest) { return handle(req); } +export async function PUT(req: NextRequest) { return handle(req); } +export async function PATCH(req: NextRequest) { return handle(req); } +export async function DELETE(req: NextRequest) { return handle(req); } + +async function handle(req: NextRequest): Promise { + const path = req.nextUrl.pathname.replace('/api/catalog-proxy/', ''); + const url = `${API}/${path}${req.nextUrl.search}`; + const headers: Record = { accept: 'application/json' }; + const cookie = req.headers.get('cookie'); + if (cookie) headers.cookie = cookie; + + const init: RequestInit = { headers }; + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + init.method = req.method; + init.body = await req.text(); + const ct = req.headers.get('content-type'); + if (ct) headers['content-type'] = ct; + } + + try { + const res = await fetch(url, init); + const data = await res.json().catch(() => ({})); + const setCookie = res.headers.get('set-cookie'); + if (setCookie) { + return NextResponse.json(data, { status: res.status, headers: { 'set-cookie': setCookie } }); + } + return NextResponse.json(data, { status: res.status }); + } catch (err) { + return NextResponse.json({ error: { code: 'PROXY_ERROR', message: String(err) } }, { status: 502 }); + } +} diff --git a/project/frontend/src/lib/api.ts b/project/frontend/src/lib/api.ts index b8374b3..97667ff 100644 --- a/project/frontend/src/lib/api.ts +++ b/project/frontend/src/lib/api.ts @@ -8,15 +8,14 @@ interface CmsPage { status: string; } -// Use env var if set, otherwise proxy through /api/catalog -// In SSR: NEXT_PUBLIC_API_URL is http://127.0.0.1:3000 (backend direct) -// In prod (relative mode): empty β†’ /api/catalog (same-origin proxy) +// Browser: use same-origin proxy route to avoid network/IP issues +// SSR: use backend URL directly (same machine) const BASE = (() => { if (typeof window !== 'undefined') { - // Browser: use same-origin relative URL (works from any network) - return ''; + // Browser: proxy through Next.js to avoid external IP issues + return '/api/catalog-proxy'; } - // Server/SSR: use backend URL from env + // Server/SSR: use backend URL directly return process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000'; })(); diff --git a/project/src/modules/admin/api/notifications.routes.ts b/project/src/modules/admin/api/notifications.routes.ts new file mode 100644 index 0000000..814e295 --- /dev/null +++ b/project/src/modules/admin/api/notifications.routes.ts @@ -0,0 +1,65 @@ +import { FastifyInstance } from 'fastify'; +import { Pool } from 'pg'; + +export function notificationsRoutes(fastify: FastifyInstance, pool: Pool) { + + // GET /api/admin/notifications + // Returns pending counts for bell icon + fastify.get('/admin/notifications', async (req) => { + const [ordersRows] = await Promise.all([ + pool.query<{ pending: string; shipped_24h: string; awaiting_payment: string }>(` + SELECT + COUNT(*) FILTER (WHERE state = 'PENDING') AS pending, + COUNT(*) FILTER ( + WHERE state = 'SHIPPED' + AND updated_at < NOW() - INTERVAL '24 hours' + ) AS shipped_24h, + COUNT(*) FILTER ( + WHERE payment_status = 'AWAITING' + AND state = 'PENDING' + ) AS awaiting_payment + FROM pos_orders + WHERE deleted_at IS NULL + AND created_at > NOW() - INTERVAL '30 days' + `), + ]); + + const row = ordersRows.rows[0]; + const items = []; + + const pending = parseInt(row?.pending ?? '0', 10); + if (pending > 0) { + items.push({ + id: 'pending_orders', + type: 'pending_orders', + count: pending, + message: 'Pedidos pendientes de procesar', + href: '/orders?state=PENDING', + }); + } + + const shipped24h = parseInt(row?.shipped_24h ?? '0', 10); + if (shipped24h > 0) { + items.push({ + id: 'stale_shipped', + type: 'stale_shipped', + count: shipped24h, + message: 'Enviados sin actualizar en +24h', + href: '/orders?state=SHIPPED&stale=true', + }); + } + + const awaiting = parseInt(row?.awaiting_payment ?? '0', 10); + if (awaiting > 0) { + items.push({ + id: 'awaiting_payment', + type: 'awaiting_payment', + count: awaiting, + message: 'Esperando confirmaciΓ³n de pago', + href: '/orders?payment_status=AWAITING', + }); + } + + return { items }; + }); +} diff --git a/project/src/modules/backoffice/api/backoffice.routes.ts b/project/src/modules/backoffice/api/backoffice.routes.ts index e996ab4..f864191 100644 --- a/project/src/modules/backoffice/api/backoffice.routes.ts +++ b/project/src/modules/backoffice/api/backoffice.routes.ts @@ -157,6 +157,29 @@ export async function registerBackofficeRoutes( throw error; } }); + + // Bell notifications for admin dashboard + app.get('/notifications', async (req) => { + const rows = await deps.pool.query<{ + pending: string; shipped_24h: string; awaiting_payment: string; + }>(` + SELECT + COUNT(*) FILTER (WHERE state = 'PENDING') AS pending, + COUNT(*) FILTER (WHERE state = 'SHIPPED' AND updated_at < NOW() - INTERVAL '24 hours') AS shipped_24h, + COUNT(*) FILTER (WHERE payment_status = 'AWAITING' AND state = 'PENDING') AS awaiting_payment + FROM pos_orders + WHERE deleted_at IS NULL AND created_at > NOW() - INTERVAL '30 days' + `); + const r = rows.rows[0] ?? { pending: '0', shipped_24h: '0', awaiting_payment: '0' }; + const items = []; + const p = parseInt(r.pending, 10); + if (p > 0) items.push({ id: 'pending_orders', type: 'pending_orders', count: p, message: 'Pedidos pendientes de procesar', href: '/orders?state=PENDING' }); + const s = parseInt(r.shipped_24h, 10); + if (s > 0) items.push({ id: 'stale_shipped', type: 'stale_shipped', count: s, message: 'Enviados sin actualizar en +24h', href: '/orders?state=SHIPPED&stale=true' }); + const a = parseInt(r.awaiting_payment, 10); + if (a > 0) items.push({ id: 'awaiting_payment', type: 'awaiting_payment', count: a, message: 'Esperando confirmaciΓ³n de pago', href: '/orders?payment_status=AWAITING' }); + return { items }; + }); } function isSecureRequest(request: FastifyRequest): boolean { diff --git a/project/src/modules/orders/api/orders.routes.ts b/project/src/modules/orders/api/orders.routes.ts index 0b53fbd..6ab948c 100644 --- a/project/src/modules/orders/api/orders.routes.ts +++ b/project/src/modules/orders/api/orders.routes.ts @@ -530,6 +530,38 @@ export async function registerOrdersRoutes( throw mapOrderError(error); } }); + + // Awaiting payment orders for admin notifications + app.get('/orders/notifications/awaiting-payment', async (request, reply) => { + const user = await deps.authenticate(request); + requireRole(user, 'admin'); + const result = await deps.pool.query<{ + id: string; + total_cents: number; + customer_email: string | null; + created_at: Date; + total_count: number; + }>( + `SELECT o.id, o.total_cents, u.email AS customer_email, o.created_at, + COUNT(*) OVER()::int AS total_count + FROM orders_orders o + LEFT JOIN identity_users u ON u.id = o.user_id + WHERE o.payment_status = 'AWAITING' + AND o.state = 'PENDING' + AND o.deleted_at IS NULL + ORDER BY o.created_at ASC + LIMIT 20`, + ); + return reply.send({ + total: result.rows[0]?.total_count ?? 0, + items: result.rows.map((row) => ({ + id: row.id, + totalCents: row.total_cents, + customerEmail: row.customer_email, + createdAt: row.created_at.toISOString(), + })), + }); + }); } function mapOrderError(error: unknown): Error { diff --git a/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..c3e982a Binary files /dev/null and b/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..6a6122c Binary files /dev/null and b/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg new file mode 100644 index 0000000..6b57d90 Binary files /dev/null and b/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ diff --git a/work/runtime-status.json b/work/runtime-status.json index 032f1ee..2cd4983 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,5 +1,5 @@ { - "feature_id": "ADMIN-CLOSURES", + "feature_id": "FRONTEND-FIXES", "stage": "build", "agent": "implementer", "action": "",