From 06c3dc3cbdc887b43b3c8435e3074ff1314d1f51 Mon Sep 17 00:00:00 2001 From: codejagaban Date: Sun, 27 Sep 2026 12:57:16 +0100 Subject: [PATCH] feat: Add dashboard attention queue --- .../admin/AttentionQueue/index.scss | 109 ++++++++++++++++ src/components/admin/AttentionQueue/index.tsx | 119 ++++++++++++++++++ src/components/admin/Dashboard/index.tsx | 7 +- src/components/admin/Dashboard/queries.ts | 83 ++++++++++-- src/components/admin/OrderHealth/index.scss | 8 -- src/components/admin/OrderHealth/index.tsx | 13 +- src/components/admin/icons/index.tsx | 18 ++- tests/unit/attention-queue.unit.spec.ts | 40 ++++++ 8 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 src/components/admin/AttentionQueue/index.scss create mode 100644 src/components/admin/AttentionQueue/index.tsx create mode 100644 tests/unit/attention-queue.unit.spec.ts diff --git a/src/components/admin/AttentionQueue/index.scss b/src/components/admin/AttentionQueue/index.scss new file mode 100644 index 0000000..321dc72 --- /dev/null +++ b/src/components/admin/AttentionQueue/index.scss @@ -0,0 +1,109 @@ +.attention-queue { + 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; + } + + > span { + color: var(--theme-elevation-450); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + } + } + + &__icon { + flex: 0 0 auto; + color: var(--theme-elevation-500); + } + + &__items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; + } + + &__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.8rem; + min-width: 0; + padding: 0.75rem 0.85rem; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-850); + background: var(--theme-elevation-50); + text-decoration: none; + transition: background-color 120ms ease; + + &:hover { + background: var(--theme-elevation-100); + } + + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: 2px; + } + + &--urgent .attention-queue__count { + color: var(--theme-error-500); + } + } + + &__copy { + display: flex; + flex-direction: column; + gap: 0.16rem; + min-width: 0; + + strong { + color: var(--theme-elevation-900); + font-size: 0.86rem; + font-weight: 600; + } + + > span { + color: var(--theme-elevation-500); + font-size: 0.76rem; + line-height: 1.35; + } + } + + &__count { + color: var(--theme-elevation-850); + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + &__arrow { + color: var(--theme-elevation-450); + font-size: 1rem; + } + + &__empty { + margin: 0; + color: var(--theme-elevation-500); + } + + @media (max-width: 62rem) { + &__items { + grid-template-columns: minmax(0, 1fr); + } + } +} diff --git a/src/components/admin/AttentionQueue/index.tsx b/src/components/admin/AttentionQueue/index.tsx new file mode 100644 index 0000000..6e61298 --- /dev/null +++ b/src/components/admin/AttentionQueue/index.tsx @@ -0,0 +1,119 @@ +import Link from 'next/link' +import React from 'react' + +import type { AttentionCounts } from '../Dashboard/queries' +import { IconAttention } from '../icons' + +import './index.scss' + +type Props = { + adminPath: string + counts: AttentionCounts +} + +type Filter = { field: string; operator: string; value: string } + +const filteredHref = (adminPath: string, collection: string, filters: Filter[] = []) => { + const path = `${adminPath}/collections/${collection}` + if (!filters.length) return path + const query = new URLSearchParams() + filters.forEach((filter, index) => { + query.set(`where[and][${index}][${filter.field}][${filter.operator}]`, filter.value) + }) + return `${path}?${query}` +} + +export const AttentionQueue: React.FC = ({ adminPath, counts }) => { + const items = [ + { + count: counts.processingOrders, + detail: 'Paid orders waiting to be handled', + href: filteredHref(adminPath, 'orders', [ + { field: 'status', operator: 'equals', value: 'processing' }, + ]), + label: 'Orders to process', + }, + { + count: counts.failedPayments, + detail: 'Payment attempts failed in the last 24 hours', + href: filteredHref(adminPath, 'transactions', [ + { field: 'status', operator: 'equals', value: 'failed' }, + ]), + label: 'Failed payments', + tone: 'urgent', + }, + { + count: counts.stalledPayments, + detail: 'Payments pending for more than 30 minutes', + href: filteredHref(adminPath, 'transactions', [ + { field: 'status', operator: 'equals', value: 'pending' }, + ]), + label: 'Stalled payments', + }, + { + count: counts.incompleteCheckouts, + detail: 'Carts left untouched for at least 24 hours', + href: filteredHref(adminPath, 'carts', [ + { field: 'purchasedAt', operator: 'exists', value: 'false' }, + ]), + label: 'Incomplete checkouts', + }, + { + count: counts.outOfStockProducts, + detail: 'Published products with no stock remaining', + href: filteredHref(adminPath, 'products', [ + { field: 'enableVariants', operator: 'not_equals', value: 'true' }, + { field: 'inventory', operator: 'less_than_equal', value: '0' }, + { field: '_status', operator: 'equals', value: 'published' }, + ]), + label: 'Products out of stock', + }, + { + count: counts.outOfStockVariants, + detail: 'Published variants with no stock remaining', + href: filteredHref(adminPath, 'variants', [ + { field: 'inventory', operator: 'less_than_equal', value: '0' }, + { field: '_status', operator: 'equals', value: 'published' }, + ]), + label: 'Variants out of stock', + }, + ].filter((item) => item.count > 0) + const total = items.reduce((sum, item) => sum + item.count, 0) + + return ( +
+
+

+ + Needs attention +

+ {total > 0 && {total} open} +
+ + {items.length === 0 ? ( +

+ You’re caught up. There is nothing urgent to handle. +

+ ) : ( +
+ {items.map((item) => ( + + + {item.label} + {item.detail} + + {item.count.toLocaleString('en-US')} + + + ))} +
+ )} +
+ ) +} diff --git a/src/components/admin/Dashboard/index.tsx b/src/components/admin/Dashboard/index.tsx index 3987b43..8ec5c10 100644 --- a/src/components/admin/Dashboard/index.tsx +++ b/src/components/admin/Dashboard/index.tsx @@ -3,6 +3,7 @@ import { getPayload } from 'payload' import React from 'react' import { AnalyticsControls } from '../AnalyticsControls' +import { AttentionQueue } from '../AttentionQueue' import { IconAverage, IconOrders, IconProducts, IconRevenue, IconStock } from '../icons' import { KpiStrip, type Kpi } from '../KpiStrip' import { OrderHealth } from '../OrderHealth' @@ -124,6 +125,7 @@ async function getOverview(filters: DashboardFilters) { return { currentDaily: current.dailyNetSales, + attention: orderData.attention, currentRevenue: current.netSales, currentOrders: current.netOrders, currentAov: current.averageOrderValue, @@ -146,7 +148,6 @@ async function getOverview(filters: DashboardFilters) { statusCounts: orderData.statusCounts, popular, lowStock, - unfinishedPayments: orderData.unfinishedPayments, window, } } @@ -231,6 +232,8 @@ export const Dashboard: React.FC<{ + +
- +

diff --git a/src/components/admin/Dashboard/queries.ts b/src/components/admin/Dashboard/queries.ts index e2eee2d..de7ec68 100644 --- a/src/components/admin/Dashboard/queries.ts +++ b/src/components/admin/Dashboard/queries.ts @@ -20,6 +20,15 @@ export type SalesAggregate = { totalSales: number } +export type AttentionCounts = { + failedPayments: number + incompleteCheckouts: number + outOfStockProducts: number + outOfStockVariants: number + processingOrders: number + stalledPayments: number +} + type SalesRow = { day: string discounts: string | number | null @@ -37,7 +46,14 @@ type SalesRow = { type StatusRow = { count: string | number; status: keyof StatusCount } type ProductRow = { product_id: number; units: string | number } -type TransactionRow = { count: string | number; status: string } +type AttentionRow = { + failed_payments: string | number + incomplete_checkouts: string | number + out_of_stock_products: string | number + out_of_stock_variants: string | number + processing_orders: string | number + stalled_payments: string | number +} const rowsFrom = (result: unknown): T[] => { if (typeof result !== 'object' || result === null || !('rows' in result)) return [] @@ -49,6 +65,18 @@ const amount = (value: string | number | null | undefined): number => { return Number.isFinite(parsed) ? parsed : 0 } +export const attentionCountsFrom = (result: unknown): AttentionCounts => { + const row = rowsFrom(result)[0] + return { + failedPayments: amount(row?.failed_payments), + incompleteCheckouts: amount(row?.incomplete_checkouts), + outOfStockProducts: amount(row?.out_of_stock_products), + outOfStockVariants: amount(row?.out_of_stock_variants), + processingOrders: amount(row?.processing_orders), + stalledPayments: amount(row?.stalled_payments), + } +} + const emptySales = (days: number): SalesAggregate => ({ averageOrderValue: 0, dailyNetSales: new Array(days).fill(0), @@ -138,7 +166,7 @@ export async function getDashboardOrderData({ ORDER BY "day" `) - const [statusResult, transactionResult, popularResult] = await Promise.all([ + const [statusResult, attentionResult, popularResult] = await Promise.all([ payload.db.drizzle.execute(sql` SELECT COALESCE("status"::text, 'processing') AS "status", COUNT(*) AS "count" FROM "orders" @@ -147,10 +175,48 @@ export async function getDashboardOrderData({ GROUP BY "status" `), payload.db.drizzle.execute(sql` - SELECT "status"::text AS "status", COUNT(*) AS "count" - FROM "transactions" - WHERE "status" = 'pending' - GROUP BY "status" + SELECT + (SELECT COUNT(*) FROM "orders" WHERE "status" = 'processing') AS "processing_orders", + ( + SELECT COUNT(*) FROM "transactions" + WHERE "status" = 'failed' AND "created_at" >= NOW() - INTERVAL '24 hours' + ) AS "failed_payments", + ( + SELECT COUNT(*) FROM "transactions" + WHERE "status" = 'pending' AND "created_at" < NOW() - INTERVAL '30 minutes' + ) AS "stalled_payments", + ( + SELECT COUNT(DISTINCT "carts"."id") + FROM "carts" + WHERE "carts"."purchased_at" IS NULL + AND "carts"."updated_at" < NOW() - INTERVAL '24 hours' + AND EXISTS ( + SELECT 1 FROM "carts_items" + WHERE "carts_items"."_parent_id" = "carts"."id" + ) + AND NOT EXISTS ( + SELECT 1 FROM "transactions" + WHERE "transactions"."cart_id" = "carts"."id" + AND "transactions"."status" IN ('pending', 'processing', 'succeeded') + ) + ) AS "incomplete_checkouts", + ( + SELECT COUNT(*) FROM "products" + WHERE "enable_variants" IS DISTINCT FROM TRUE + AND COALESCE("inventory", 0) <= 0 + AND "deleted_at" IS NULL + AND "_status" = 'published' + ) AS "out_of_stock_products", + ( + SELECT COUNT(*) + FROM "variants" + INNER JOIN "products" ON "products"."id" = "variants"."product_id" + WHERE COALESCE("variants"."inventory", 0) <= 0 + AND "variants"."deleted_at" IS NULL + AND "variants"."_status" = 'published' + AND "products"."deleted_at" IS NULL + AND "products"."_status" = 'published' + ) AS "out_of_stock_variants" `), payload.db.drizzle.execute(sql` SELECT "items"."product_id", SUM("items"."quantity") AS "units" @@ -203,6 +269,7 @@ export async function getDashboardOrderData({ } return { + attention: attentionCountsFrom(attentionResult), current, prior, popular: rowsFrom(popularResult).map((row) => ({ @@ -210,9 +277,5 @@ export async function getDashboardOrderData({ units: amount(row.units), })), statusCounts, - unfinishedPayments: rowsFrom(transactionResult).reduce( - (total, row) => total + amount(row.count), - 0, - ), } } diff --git a/src/components/admin/OrderHealth/index.scss b/src/components/admin/OrderHealth/index.scss index 56bfafa..afdfddf 100644 --- a/src/components/admin/OrderHealth/index.scss +++ b/src/components/admin/OrderHealth/index.scss @@ -124,14 +124,6 @@ &--cancelled { background: var(--bad); } } - &__aside { - margin: 0.9rem 0 0; - padding-top: 0.8rem; - border-top: 1px solid var(--theme-elevation-100); - font-size: 0.85rem; - color: var(--theme-elevation-500); - } - &__empty { margin: 0; color: var(--theme-elevation-450); } } diff --git a/src/components/admin/OrderHealth/index.tsx b/src/components/admin/OrderHealth/index.tsx index 5b9aa74..c373722 100644 --- a/src/components/admin/OrderHealth/index.tsx +++ b/src/components/admin/OrderHealth/index.tsx @@ -8,7 +8,7 @@ import './index.scss' export type StatusCount = { completed: number; processing: number; cancelled: number; refunded: number } -type Props = { counts: StatusCount; unfinishedPayments: number } +type Props = { counts: StatusCount } /** * The state of the order pipeline, as one bar. @@ -18,7 +18,7 @@ type Props = { counts: StatusCount; unfinishedPayments: number } * healthy to unhealthy so a growing problem shows as the bar filling from the right. * Hovering a segment names it and adds its share, which the key below cannot show. */ -export const OrderHealth: React.FC = ({ counts, unfinishedPayments }) => { +export const OrderHealth: React.FC = ({ counts }) => { const [hover, setHover] = React.useState(null) const total = counts.completed + counts.processing + counts.cancelled + counts.refunded @@ -70,7 +70,7 @@ export const OrderHealth: React.FC = ({ counts, unfinishedPayments }) =>

    - {segments.map((s, i) => ( + {segments.map((s) => (
  • {s.label} {s.n} @@ -79,13 +79,6 @@ export const OrderHealth: React.FC = ({ counts, unfinishedPayments }) =>
)} - - {unfinishedPayments > 0 && ( -

- {unfinishedPayments} checkout{unfinishedPayments === 1 ? '' : 's'} started at the payment - step without finishing. -

- )} ) } diff --git a/src/components/admin/icons/index.tsx b/src/components/admin/icons/index.tsx index 62363c1..7af21e5 100644 --- a/src/components/admin/icons/index.tsx +++ b/src/components/admin/icons/index.tsx @@ -38,7 +38,10 @@ export const IconRevenue: React.FC = (p) => ( /** Orders — a shopping bag. */ export const IconOrders: React.FC = (p) => ( - + ) @@ -84,10 +87,21 @@ export const IconHealth: React.FC = (p) => ( ) +/** Attention queue — three work marks with one open task. */ +export const IconAttention: React.FC = (p) => ( + + + + +) + /** Low stock — a box with a warning. */ export const IconStock: React.FC = (p) => ( - + diff --git a/tests/unit/attention-queue.unit.spec.ts b/tests/unit/attention-queue.unit.spec.ts new file mode 100644 index 0000000..73d48b3 --- /dev/null +++ b/tests/unit/attention-queue.unit.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { attentionCountsFrom } from '@/components/admin/Dashboard/queries' + +describe('dashboard attention queue', () => { + it('maps database counts into merchant actions', () => { + expect( + attentionCountsFrom({ + rows: [ + { + failed_payments: '2', + incomplete_checkouts: '3', + out_of_stock_products: '4', + out_of_stock_variants: '5', + processing_orders: '6', + stalled_payments: '7', + }, + ], + }), + ).toEqual({ + failedPayments: 2, + incompleteCheckouts: 3, + outOfStockProducts: 4, + outOfStockVariants: 5, + processingOrders: 6, + stalledPayments: 7, + }) + }) + + it('returns a safe empty queue when the database returns no row', () => { + expect(attentionCountsFrom(undefined)).toEqual({ + failedPayments: 0, + incompleteCheckouts: 0, + outOfStockProducts: 0, + outOfStockVariants: 0, + processingOrders: 0, + stalledPayments: 0, + }) + }) +})