Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/admin/Dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -147,6 +148,7 @@ async function getOverview(filters: DashboardFilters) {
products: productCount.totalDocs,
statusCounts: orderData.statusCounts,
popular,
recentOrders: orderData.recentOrders,
lowStock,
window,
}
Expand Down Expand Up @@ -234,6 +236,8 @@ export const Dashboard: React.FC<{

<AttentionQueue adminPath={adminPath} counts={s.attention} />

<RecentOrders adminPath={adminPath} orders={s.recentOrders} timeZone={s.timeZone} />

<div className={`${baseClass}__split`}>
<RevenueTrend
breakdown={[
Expand Down
59 changes: 58 additions & 1 deletion src/components/admin/Dashboard/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export type AttentionCounts = {
stalledPayments: number
}

export type RecentOrder = {
createdAt: string
currency: string
customer: string
fulfillmentStatus: string
id: number
paymentStatus: string
total: number
}

type SalesRow = {
day: string
discounts: string | number | null
Expand All @@ -54,6 +64,15 @@ type AttentionRow = {
processing_orders: string | number
stalled_payments: string | number
}
type RecentOrderRow = {
amount: string | number | null
created_at: Date | string
currency: string | null
customer: string | null
fulfillment_status: string | null
id: number
payment_status: string | null
}

const rowsFrom = <T>(result: unknown): T[] => {
if (typeof result !== 'object' || result === null || !('rows' in result)) return []
Expand All @@ -77,6 +96,17 @@ export const attentionCountsFrom = (result: unknown): AttentionCounts => {
}
}

export const recentOrdersFrom = (result: unknown): RecentOrder[] =>
rowsFrom<RecentOrderRow>(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<number>(days).fill(0),
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -276,6 +332,7 @@ export async function getDashboardOrderData({
productID: row.product_id,
units: amount(row.units),
})),
recentOrders: recentOrdersFrom(recentResult),
statusCounts,
}
}
166 changes: 166 additions & 0 deletions src/components/admin/RecentOrders/index.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
84 changes: 84 additions & 0 deletions src/components/admin/RecentOrders/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ adminPath, orders, timeZone }) => (
<section className="recent-orders">
<header className="recent-orders__head">
<h3>
<IconOrders className="recent-orders__icon" />
Recent orders
</h3>
<Link href={`${adminPath}/collections/orders`}>View all</Link>
</header>

{orders.length === 0 ? (
<p className="recent-orders__empty">New orders will appear here.</p>
) : (
<div className="recent-orders__table" aria-label="Five most recent orders">
<div className="recent-orders__labels" aria-hidden="true">
<span>Order</span>
<span>Customer</span>
<span>Total</span>
<span>Payment</span>
<span>Fulfilment</span>
<span>Date</span>
</div>
{orders.map((order) => (
<Link
className="recent-orders__row"
href={`${adminPath}/collections/orders/${order.id}`}
key={order.id}
>
<strong>#{order.id}</strong>
<span className="recent-orders__customer">{order.customer}</span>
<span className="recent-orders__money">
{money(order.total, order.currency)}
</span>
<span
className={`recent-orders__status recent-orders__status--payment recent-orders__status--${order.paymentStatus}`}
>
{words(order.paymentStatus)}
</span>
<span className="recent-orders__status recent-orders__status--fulfilment">
{fulfillmentLabel(order.fulfillmentStatus)}
</span>
<time dateTime={order.createdAt}>
{new Date(order.createdAt).toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
timeZone,
})}
</time>
</Link>
))}
</div>
)}
</section>
)
Loading
Loading