diff --git a/src/components/admin/Dashboard/index.tsx b/src/components/admin/Dashboard/index.tsx index 8ec5c10..1008552 100644 --- a/src/components/admin/Dashboard/index.tsx +++ b/src/components/admin/Dashboard/index.tsx @@ -8,6 +8,7 @@ import { IconAverage, IconOrders, IconProducts, IconRevenue, IconStock } from '. import { KpiStrip, type Kpi } from '../KpiStrip' import { OrderHealth } from '../OrderHealth' import { PopularProducts, type PopularProduct } from '../PopularProducts' +import { RecentOrders } from '../RecentOrders' import { RevenueTrend } from '../RevenueTrend' import { DEFAULT_CURRENCY_CODE } from '@/currencies' import { @@ -147,6 +148,7 @@ async function getOverview(filters: DashboardFilters) { products: productCount.totalDocs, statusCounts: orderData.statusCounts, popular, + recentOrders: orderData.recentOrders, lowStock, window, } @@ -234,6 +236,8 @@ export const Dashboard: React.FC<{ + +
(result: unknown): T[] => { if (typeof result !== 'object' || result === null || !('rows' in result)) return [] @@ -77,6 +96,17 @@ export const attentionCountsFrom = (result: unknown): AttentionCounts => { } } +export const recentOrdersFrom = (result: unknown): RecentOrder[] => + rowsFrom(result).map((row) => ({ + createdAt: new Date(row.created_at).toISOString(), + currency: row.currency || 'USD', + customer: row.customer || 'Guest customer', + fulfillmentStatus: row.fulfillment_status || 'processing', + id: row.id, + paymentStatus: row.payment_status || 'not_recorded', + total: amount(row.amount), + })) + const emptySales = (days: number): SalesAggregate => ({ averageOrderValue: 0, dailyNetSales: new Array(days).fill(0), @@ -166,7 +196,7 @@ export async function getDashboardOrderData({ ORDER BY "day" `) - const [statusResult, attentionResult, popularResult] = await Promise.all([ + const [statusResult, attentionResult, popularResult, recentResult] = await Promise.all([ payload.db.drizzle.execute(sql` SELECT COALESCE("status"::text, 'processing') AS "status", COUNT(*) AS "count" FROM "orders" @@ -231,6 +261,32 @@ export async function getDashboardOrderData({ ORDER BY "units" DESC LIMIT 5 `), + payload.db.drizzle.execute(sql` + SELECT + "orders"."id", + COALESCE( + NULLIF("users"."name", ''), + NULLIF("orders"."customer_email", ''), + NULLIF("users"."email", ''), + 'Guest customer' + ) AS "customer", + COALESCE("orders"."amount", 0) AS "amount", + COALESCE("orders"."currency"::text, ${currency}) AS "currency", + COALESCE("orders"."status"::text, 'processing') AS "fulfillment_status", + "latest_transaction"."status"::text AS "payment_status", + "orders"."created_at" + FROM "orders" + LEFT JOIN "users" ON "users"."id" = "orders"."customer_id" + LEFT JOIN LATERAL ( + SELECT "transactions"."status" + FROM "transactions" + WHERE "transactions"."order_id" = "orders"."id" + ORDER BY "transactions"."updated_at" DESC, "transactions"."id" DESC + LIMIT 1 + ) AS "latest_transaction" ON TRUE + ORDER BY "orders"."created_at" DESC, "orders"."id" DESC + LIMIT 5 + `), ]) const current = emptySales(days) @@ -276,6 +332,7 @@ export async function getDashboardOrderData({ productID: row.product_id, units: amount(row.units), })), + recentOrders: recentOrdersFrom(recentResult), statusCounts, } } diff --git a/src/components/admin/RecentOrders/index.scss b/src/components/admin/RecentOrders/index.scss new file mode 100644 index 0000000..511e248 --- /dev/null +++ b/src/components/admin/RecentOrders/index.scss @@ -0,0 +1,166 @@ +.recent-orders { + padding: 1.15rem 1.25rem; + border: 1px solid var(--theme-elevation-100); + border-radius: var(--style-radius-s, 3px); + background: var(--theme-elevation-0); + + &__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; + + h3 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 0.95rem; + font-weight: 600; + } + + a { + color: var(--theme-elevation-650); + font-size: 0.8rem; + text-decoration: none; + + &:hover { + color: var(--theme-elevation-900); + } + } + } + + &__icon { + flex: 0 0 auto; + color: var(--theme-elevation-500); + } + + &__table { + display: grid; + } + + &__labels, + &__row { + display: grid; + grid-template-columns: 0.65fr minmax(8rem, 1.7fr) 0.9fr 1fr 1fr 0.8fr; + gap: 1rem; + align-items: center; + } + + &__labels { + padding: 0 0.7rem 0.45rem; + color: var(--theme-elevation-450); + font-size: 0.72rem; + } + + &__row { + min-width: 0; + padding: 0.68rem 0.7rem; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-700); + font-size: 0.82rem; + text-decoration: none; + transition: background-color 120ms ease; + + &:nth-child(even) { + background: var(--theme-elevation-50); + } + + &:hover { + background: var(--theme-elevation-100); + } + + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: 1px; + } + + strong, + time { + color: var(--theme-elevation-850); + font-variant-numeric: tabular-nums; + } + } + + &__customer { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__money { + color: var(--theme-elevation-850); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + &__status { + color: var(--theme-elevation-650); + + &--succeeded { + color: var(--theme-success-600, #16803b); + } + + &--failed { + color: var(--theme-error-500, #b42318); + } + + &--pending, + &--processing { + color: var(--theme-warning-600, #9a6700); + } + } + + &__empty { + margin: 0; + color: var(--theme-elevation-500); + } + + @media (max-width: 68rem) { + &__labels { + display: none; + } + + &__table { + gap: 0.4rem; + } + + &__row { + grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr) auto; + gap: 0.25rem 0.75rem; + background: var(--theme-elevation-50); + + strong { + grid-column: 1; + grid-row: 1; + } + + .recent-orders__customer { + grid-column: 2; + grid-row: 1; + } + + .recent-orders__money { + grid-column: 3; + grid-row: 1; + } + + .recent-orders__status--payment { + grid-column: 1; + grid-row: 2; + } + + .recent-orders__status--fulfilment { + grid-column: 2; + grid-row: 2; + } + + time { + grid-column: 3; + grid-row: 2; + } + } + } +} diff --git a/src/components/admin/RecentOrders/index.tsx b/src/components/admin/RecentOrders/index.tsx new file mode 100644 index 0000000..bf776e2 --- /dev/null +++ b/src/components/admin/RecentOrders/index.tsx @@ -0,0 +1,84 @@ +import Link from 'next/link' +import React from 'react' + +import type { RecentOrder } from '../Dashboard/queries' +import { IconOrders } from '../icons' + +import './index.scss' + +type Props = { + adminPath: string + orders: RecentOrder[] + timeZone: string +} + +const money = (minor: number, currency: string) => + new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(minor / 100) + +const words = (value: string) => + value + .replaceAll('_', ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()) + +const fulfillmentLabel = (status: string) => { + if (status === 'processing') return 'Unfulfilled' + if (status === 'completed') return 'Fulfilled' + if (status === 'refunded') return 'Refunded' + if (status === 'cancelled') return 'Cancelled' + return words(status) +} + +export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) => ( +
+
+

+ + Recent orders +

+ View all +
+ + {orders.length === 0 ? ( +

New orders will appear here.

+ ) : ( +
+ + {orders.map((order) => ( + + #{order.id} + {order.customer} + + {money(order.total, order.currency)} + + + {words(order.paymentStatus)} + + + {fulfillmentLabel(order.fulfillmentStatus)} + + + + ))} +
+ )} +
+) diff --git a/tests/unit/recent-orders.unit.spec.ts b/tests/unit/recent-orders.unit.spec.ts new file mode 100644 index 0000000..5fb19ac --- /dev/null +++ b/tests/unit/recent-orders.unit.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { recentOrdersFrom } from '@/components/admin/Dashboard/queries' + +describe('recent dashboard orders', () => { + it('normalizes bounded order rows for display', () => { + expect( + recentOrdersFrom({ + rows: [ + { + amount: '7600', + created_at: '2026-09-27T10:00:00.000Z', + currency: 'GBP', + customer: 'Trust Jamin', + fulfillment_status: 'processing', + id: 319, + payment_status: 'succeeded', + }, + ], + }), + ).toEqual([ + { + createdAt: '2026-09-27T10:00:00.000Z', + currency: 'GBP', + customer: 'Trust Jamin', + fulfillmentStatus: 'processing', + id: 319, + paymentStatus: 'succeeded', + total: 7600, + }, + ]) + }) + + it('labels missing legacy payment data without pretending it succeeded', () => { + expect( + recentOrdersFrom({ + rows: [ + { + amount: null, + created_at: new Date('2026-09-27T10:00:00.000Z'), + currency: null, + customer: null, + fulfillment_status: null, + id: 1, + payment_status: null, + }, + ], + })[0], + ).toMatchObject({ + currency: 'USD', + customer: 'Guest customer', + fulfillmentStatus: 'processing', + paymentStatus: 'not_recorded', + total: 0, + }) + }) +})