Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { OrderDetail } from '@/features/orders/order-detail';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('orders');
return { title: t('detailTitle') };
}
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<div className="p-8">
<OrderDetail orderId={id} />
</div>
);
}
+10 -9
View File
@@ -1,22 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { OrdersTable } from '@/features/orders/orders-table';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.orders');
const t = await getTranslations('orders');
return { title: t('title') };
}
export default async function OrdersPage() {
const t = await getTranslations('pages.orders');
const t = await getTranslations('orders');
return (
<PageScaffold
title={t('title')}
description={t('body')}
permission="order.read"
milestone="M5 — orders"
/>
<div className="space-y-6 p-8">
<header>
<h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{t('description')}</p>
</header>
<OrdersTable />
</div>
);
}
@@ -0,0 +1,233 @@
'use client';
import Link from 'next/link';
import { useFormatter, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types';
import { Badge, Button, Input, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime, formatMoney } from '@/lib/format';
import { STATUS_VARIANT } from './orders-table';
/**
* Which actions to offer, mirroring the server's transition table.
*
* Duplicated deliberately rather than fetched: the server is the authority and
* rejects anything illegal, so this list only decides which buttons are worth
* showing. Getting it out of step shows a button that fails, never a transition
* that should not happen.
*/
const NEXT_STATUSES: Record<OrderStatus, readonly OrderStatus[]> = {
PENDING: ['CONFIRMED', 'CANCELLED'],
CONFIRMED: ['FULFILLED', 'CANCELLED'],
FULFILLED: ['COMPLETED'],
COMPLETED: [],
CANCELLED: [],
};
export function OrderDetail({ orderId }: { orderId: string }) {
const t = useTranslations('orders');
const format = useFormatter();
const { can } = useSession();
const [order, setOrder] = useState<Order | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
try {
const result = await browserApi.ordersAdmin.getOrder(orderId);
if (!cancelled) setOrder(result);
} catch (caught) {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}
void load();
return () => {
cancelled = true;
};
}, [orderId, t]);
async function move(status: OrderStatus) {
setBusy(true);
setError(null);
try {
setOrder(await browserApi.ordersAdmin.updateOrderStatus(orderId, status, reason || null));
setReason('');
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('updateFailed'));
} finally {
setBusy(false);
}
}
if (error && !order) {
return (
<p role="alert" className="text-danger text-sm">
{error}
</p>
);
}
if (!order) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const next = NEXT_STATUSES[order.status];
const money = (amount: number) => formatMoney({ amount, currency: order.currency }, format);
return (
<div className="space-y-8">
<header className="flex flex-wrap items-center gap-3">
<Link href="/orders" className="text-ink-500 hover:text-ink-950 text-sm">
← {t('backToList')}
</Link>
<h1 className="font-mono text-xl font-bold">{order.orderNumber}</h1>
<Badge variant={STATUS_VARIANT[order.status]}>{t(`status.${order.status}`)}</Badge>
<Badge variant="outline">{t(`payment.${order.paymentStatus}`)}</Badge>
</header>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? (
<section className="border-ink-200 space-y-3 border bg-white p-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('actions')}</h2>
{next.includes('CANCELLED') ? (
<Input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={t('reasonPlaceholder')}
className="max-w-md"
/>
) : null}
<div className="flex flex-wrap gap-2">
{next.map((status) => (
<Button
key={status}
size="sm"
variant={status === 'CANCELLED' ? 'destructive' : 'default'}
disabled={busy || (status === 'CANCELLED' && reason.trim().length === 0)}
onClick={() => void move(status)}
>
{t(`action.${status}`)}
</Button>
))}
</div>
{next.includes('FULFILLED') ? (
<p className="text-ink-400 text-xs">{t('fulfilHint')}</p>
) : null}
</section>
) : null}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
<section className="border-ink-200 border bg-white">
<h2 className="border-ink-200 border-b px-6 py-4 text-xs font-semibold uppercase tracking-widest">
{t('items')}
</h2>
<table className="w-full text-sm">
<tbody className="divide-ink-100 divide-y">
{order.lines.map((line) => (
<tr key={line.id}>
<td className="px-6 py-3">
<p className="font-medium">{line.productName}</p>
<p className="text-ink-500 text-xs">{line.variantTitle}</p>
<p className="text-ink-400 font-mono text-[0.625rem]">{line.sku}</p>
</td>
<td className="px-6 py-3 text-right tabular-nums">
{money(line.unitPrice.amount)} × {line.quantity}
</td>
<td className="px-6 py-3 text-right font-semibold tabular-nums">
{money(line.lineTotal.amount)}
</td>
</tr>
))}
</tbody>
</table>
<dl className="border-ink-200 space-y-2 border-t px-6 py-4 text-sm">
<Row label={t('subtotal')} value={money(order.subtotal.amount)} />
<Row label={t('shipping')} value={money(order.shipping.amount)} />
<Row label={t('discount')} value={money(order.discount.amount)} />
<div className="border-ink-200 flex justify-between border-t pt-2 font-semibold">
<dt>{t('total')}</dt>
<dd className="tabular-nums">{money(order.total.amount)}</dd>
</div>
</dl>
</section>
<aside className="space-y-6">
<section className="border-ink-200 border bg-white p-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('customer')}</h2>
<p className="mt-3 text-sm font-medium">{order.shippingAddress.fullName}</p>
<p className="text-ink-500 text-sm">{order.email}</p>
<p className="text-ink-500 text-sm">{order.phone}</p>
</section>
<section className="border-ink-200 border bg-white p-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('shippingTo')}</h2>
<address className="text-ink-600 mt-3 text-sm not-italic leading-relaxed">
{[
order.shippingAddress.line1,
order.shippingAddress.ward,
order.shippingAddress.district,
order.shippingAddress.province,
]
.filter(Boolean)
.join(', ')}
</address>
{order.customerNote ? (
<p className="border-ink-100 text-ink-500 mt-3 border-t pt-3 text-xs">
{order.customerNote}
</p>
) : null}
</section>
<section className="border-ink-200 border bg-white p-6 text-xs">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('timeline')}</h2>
<dl className="text-ink-500 mt-3 space-y-1">
<Row label={t('placed')} value={formatDateTime(order.placedAt, format)} />
{order.confirmedAt ? (
<Row label={t('confirmed')} value={formatDateTime(order.confirmedAt, format)} />
) : null}
{order.cancelledAt ? (
<Row label={t('cancelled')} value={formatDateTime(order.cancelledAt, format)} />
) : null}
</dl>
{order.cancelReason ? <p className="text-danger mt-2">{order.cancelReason}</p> : null}
</section>
</aside>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-4">
<dt className="text-ink-500">{label}</dt>
<dd className="text-right tabular-nums">{value}</dd>
</div>
);
}
@@ -0,0 +1,169 @@
'use client';
import Link from 'next/link';
import { useFormatter, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type OrderListItem, type OrderStatus } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatDateTime, formatMoney } from '@/lib/format';
const STATUS_FILTERS = [
undefined,
'PENDING',
'CONFIRMED',
'FULFILLED',
'COMPLETED',
'CANCELLED',
] as const;
export const STATUS_VARIANT: Record<OrderStatus, 'success' | 'warning' | 'neutral' | 'solid'> = {
PENDING: 'warning',
CONFIRMED: 'solid',
FULFILLED: 'success',
COMPLETED: 'success',
CANCELLED: 'neutral',
};
export function OrdersTable() {
const t = useTranslations('orders');
const format = useFormatter();
const { can } = useSession();
const [orders, setOrders] = useState<OrderListItem[] | null>(null);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState<OrderStatus | undefined>(undefined);
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
try {
const result = await browserApi.ordersAdmin.listOrders({
status,
q: query || undefined,
perPage: 50,
});
if (cancelled) return;
setOrders([...result.items]);
setTotal(result.pageInfo.totalItems);
} catch (caught) {
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
}
}
// Debounced so typing an order number does not fire a request per keystroke.
const timer = setTimeout(() => void run(), query ? 300 : 0);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [status, query, t]);
if (!can(PERMISSIONS.ORDER_READ)) {
return <p className="text-ink-500 text-sm">{t('noPermission')}</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className="max-w-xs"
/>
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map((value) => (
<button
key={value ?? 'all'}
type="button"
onClick={() => setStatus(value as OrderStatus | undefined)}
aria-pressed={status === value}
className={cn(
'border px-3 py-1.5 text-xs font-medium transition-colors',
status === value
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 text-ink-600 hover:border-ink-400',
)}
>
{value ? t(`status.${value}`) : t('status.ALL')}
</button>
))}
</div>
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{!orders ? (
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : orders.length === 0 ? (
<p className="text-ink-500 py-16 text-center text-sm">{t('empty')}</p>
) : (
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr className="text-ink-500 text-[0.625rem] uppercase tracking-widest">
<th className="px-4 py-3 font-semibold">{t('column.order')}</th>
<th className="px-4 py-3 font-semibold">{t('column.customer')}</th>
<th className="px-4 py-3 font-semibold">{t('column.status')}</th>
<th className="px-4 py-3 font-semibold">{t('column.items')}</th>
<th className="px-4 py-3 font-semibold">{t('column.total')}</th>
<th className="px-4 py-3 font-semibold">{t('column.placed')}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-ink-100 divide-y">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-ink-50/60">
<td className="px-4 py-3 font-mono text-xs font-semibold">
<Link href={`/orders/${order.id}`} className="hover:underline">
{order.orderNumber}
</Link>
</td>
<td className="px-4 py-3">
<p className="font-medium">{order.customerName}</p>
<p className="text-ink-500 text-xs">{order.email}</p>
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[order.status]}>
{t(`status.${order.status}`)}
</Badge>
</td>
<td className="px-4 py-3 tabular-nums">{order.itemCount}</td>
<td className="px-4 py-3 tabular-nums">{formatMoney(order.total, format)}</td>
<td className="text-ink-500 px-4 py-3 text-xs">
{formatDateTime(order.placedAt, format)}
</td>
<td className="px-4 py-3 text-right">
<Button size="sm" variant="secondary" asChild>
<Link href={`/orders/${order.id}`}>{t('view')}</Link>
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -2,15 +2,16 @@
import Image from 'next/image';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { MINOR_UNIT_SCALE, PERMISSIONS, type AdminProductListItem, type Money } from '@sport/types';
import { PERMISSIONS, type AdminProductListItem } from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
import { formatMoney } from '@/lib/format';
const STATUS_VARIANT = {
ACTIVE: 'success',
@@ -18,19 +19,9 @@ const STATUS_VARIANT = {
ARCHIVED: 'neutral',
} as const;
/** Admin money display. VND has no minor unit, so this is a plain group format. */
function formatMoney(money: Money): string {
const scale = MINOR_UNIT_SCALE[money.currency];
return new Intl.NumberFormat('vi-VN', {
style: 'currency',
currency: money.currency,
minimumFractionDigits: scale,
maximumFractionDigits: scale,
}).format(money.amount / 10 ** scale);
}
export function ProductsTable() {
const t = useTranslations('catalog');
const format = useFormatter();
const { can } = useSession();
const [items, setItems] = useState<AdminProductListItem[] | null>(null);
@@ -185,8 +176,8 @@ export function ProductsTable() {
<td className="text-ink-600 px-4 py-3">
{product.priceRange
? product.priceRange.min.amount === product.priceRange.max.amount
? formatMoney(product.priceRange.min)
: `${formatMoney(product.priceRange.min)} – ${formatMoney(product.priceRange.max)}`
? formatMoney(product.priceRange.min, format)
: `${formatMoney(product.priceRange.min, format)} – ${formatMoney(product.priceRange.max, format)}`
: '—'}
</td>
<td className="px-4 py-3">
+32
View File
@@ -0,0 +1,32 @@
import type { useFormatter } from 'next-intl';
import { MINOR_UNIT_SCALE, type Money } from '@sport/types';
type Formatter = ReturnType<typeof useFormatter>;
/**
* Money and timestamps become display strings exactly here.
*
* The formatter comes from next-intl rather than a hardcoded `'vi-VN'`. The
* admin is bilingual — an operator who switches to English was still reading
* Vietnamese grouping and `12/8/2026` date order, which for an order table is
* not cosmetic: `12/8` and `8/12` are different days.
*/
export function formatMoney(money: Money, format: Formatter): string {
const scale = MINOR_UNIT_SCALE[money.currency];
return format.number(money.amount / 10 ** scale, {
style: 'currency',
currency: money.currency,
minimumFractionDigits: scale,
maximumFractionDigits: scale,
});
}
/** Date and time together — an order table needs both to be useful. */
export function formatDateTime(value: string, format: Formatter): string {
return format.dateTime(new Date(value), {
dateStyle: 'medium',
timeStyle: 'short',
});
}
+55
View File
@@ -226,5 +226,60 @@
"anyColourway": "All colourways",
"moveLeft": "Move earlier",
"moveRight": "Move later"
},
"orders": {
"title": "Orders",
"detailTitle": "Order",
"description": "Placed orders, their stock reservations and where each one is in its lifecycle.",
"searchPlaceholder": "Order number, name, email or phone",
"count": "{count} orders",
"empty": "No orders match.",
"view": "View",
"backToList": "Orders",
"noPermission": "You do not have permission to view orders.",
"loadFailed": "Could not load orders.",
"updateFailed": "Could not update this order.",
"actions": "Actions",
"reasonPlaceholder": "Reason for cancelling (required)",
"fulfilHint": "Fulfilling ships the reserved stock and writes a ledger entry.",
"items": "Items",
"subtotal": "Subtotal",
"shipping": "Shipping",
"discount": "Discount",
"total": "Total",
"customer": "Customer",
"shippingTo": "Shipping to",
"timeline": "Timeline",
"placed": "Placed",
"confirmed": "Confirmed",
"cancelled": "Cancelled",
"status": {
"ALL": "All",
"PENDING": "Pending",
"CONFIRMED": "Confirmed",
"FULFILLED": "Fulfilled",
"COMPLETED": "Completed",
"CANCELLED": "Cancelled"
},
"action": {
"CONFIRMED": "Confirm",
"FULFILLED": "Mark fulfilled",
"COMPLETED": "Complete",
"CANCELLED": "Cancel order"
},
"payment": {
"UNPAID": "Unpaid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partly refunded",
"REFUNDED": "Refunded"
},
"column": {
"order": "Order",
"customer": "Customer",
"status": "Status",
"items": "Items",
"total": "Total",
"placed": "Placed"
}
}
}
+55
View File
@@ -226,5 +226,60 @@
"anyColourway": "Mọi màu",
"moveLeft": "Chuyển lên trước",
"moveRight": "Chuyển xuống sau"
},
"orders": {
"title": "Đơn hàng",
"detailTitle": "Đơn hàng",
"description": "Đơn đã đặt, phần tồn kho đang giữ và trạng thái hiện tại của từng đơn.",
"searchPlaceholder": "Mã đơn, tên, email hoặc số điện thoại",
"count": "{count} đơn hàng",
"empty": "Không có đơn nào phù hợp.",
"view": "Xem",
"backToList": "Đơn hàng",
"noPermission": "Bạn không có quyền xem đơn hàng.",
"loadFailed": "Không tải được đơn hàng.",
"updateFailed": "Không cập nhật được đơn này.",
"actions": "Thao tác",
"reasonPlaceholder": "Lý do huỷ đơn (bắt buộc)",
"fulfilHint": "Hoàn tất sẽ xuất phần hàng đang giữ và ghi vào sổ nhật ký kho.",
"items": "Sản phẩm",
"subtotal": "Tạm tính",
"shipping": "Vận chuyển",
"discount": "Giảm giá",
"total": "Tổng cộng",
"customer": "Khách hàng",
"shippingTo": "Giao đến",
"timeline": "Diễn biến",
"placed": "Đặt hàng",
"confirmed": "Xác nhận",
"cancelled": "Huỷ",
"status": {
"ALL": "Tất cả",
"PENDING": "Chờ xử lý",
"CONFIRMED": "Đã xác nhận",
"FULFILLED": "Đã giao",
"COMPLETED": "Hoàn tất",
"CANCELLED": "Đã huỷ"
},
"action": {
"CONFIRMED": "Xác nhận",
"FULFILLED": "Đánh dấu đã giao",
"COMPLETED": "Hoàn tất",
"CANCELLED": "Huỷ đơn"
},
"payment": {
"UNPAID": "Chưa thanh toán",
"PAID": "Đã thanh toán",
"PARTIALLY_REFUNDED": "Hoàn một phần",
"REFUNDED": "Đã hoàn tiền"
},
"column": {
"order": "Mã đơn",
"customer": "Khách hàng",
"status": "Trạng thái",
"items": "Số món",
"total": "Tổng tiền",
"placed": "Đặt lúc"
}
}
}