Stage M5 and Stage M6
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user