Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app. Co-authored-by: Cursor <cursoragent@cursor.com>
627 lines
21 KiB
TypeScript
627 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { CreditCard, Eye, RotateCcw, Search, ShoppingBag, Trash2 } from 'lucide-react'
|
|
import { useLocale } from '@meshkee/dashboard-ui'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { PageTitle } from '../components/PageTitle'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { EmptyTableRow } from '../components/EmptyState'
|
|
import { OrderItemsModal } from '../components/OrderItemsModal'
|
|
import { OrderStepModal } from '../components/OrderStepModal'
|
|
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
|
|
import { Pagination } from '../components/Pagination'
|
|
import { Tooltip } from '../components/Tooltip'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError, isAbortError } from '../lib/api'
|
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
|
import {
|
|
listOrders,
|
|
removeOrder,
|
|
type Order,
|
|
type OrdersListResponse,
|
|
type OrderSource,
|
|
} from '../services/orderService'
|
|
import {
|
|
getSettings,
|
|
DEFAULT_ORDER_PROCESS_STEPS,
|
|
type OrderProcessStep,
|
|
} from '../services/settingsService'
|
|
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
|
import { stepBadgeStyle } from '../utils/stepColors'
|
|
import filterStyles from '../components/ListFiltersPanel.module.css'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import styles from './OrdersPage.module.css'
|
|
|
|
const PAGE_SIZE = 24
|
|
const COLUMN_COUNT = 8
|
|
|
|
interface AppliedFilters {
|
|
orderNumber?: string
|
|
customerQuery?: string
|
|
dateFrom?: string
|
|
dateTo?: string
|
|
minTotal?: number
|
|
maxTotal?: number
|
|
}
|
|
|
|
function displayName(order: Order) {
|
|
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
|
return name || '—'
|
|
}
|
|
|
|
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
|
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
|
return {
|
|
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
|
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
|
}
|
|
}
|
|
|
|
function totalItemQuantity(order: Order) {
|
|
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
|
}
|
|
|
|
function defaultFaForLabel(label: string | null | undefined) {
|
|
const trimmed = label?.trim()
|
|
if (!trimmed) return undefined
|
|
return DEFAULT_ORDER_PROCESS_STEPS.find(
|
|
(step) => step.label.toLowerCase() === trimmed.toLowerCase(),
|
|
)?.labelFa
|
|
}
|
|
|
|
function stepLabel(
|
|
steps: OrderProcessStep[],
|
|
processStepId: string,
|
|
processStepLabel?: string | null,
|
|
processStepLabelFa?: string | null,
|
|
locale: 'en' | 'fa' = 'en',
|
|
) {
|
|
const step =
|
|
steps.find((item) => item.id === processStepId) ??
|
|
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId)
|
|
|
|
if (locale === 'fa') {
|
|
let fa = processStepLabelFa?.trim() || step?.labelFa?.trim()
|
|
|
|
if (fa && processStepLabel?.trim() && fa === processStepLabel.trim()) {
|
|
fa = undefined
|
|
}
|
|
|
|
fa = fa || defaultFaForLabel(processStepLabel) || defaultFaForLabel(step?.label)
|
|
|
|
if (fa) return fa
|
|
}
|
|
|
|
if (processStepLabel?.trim()) return processStepLabel.trim()
|
|
return step?.label ?? processStepId
|
|
}
|
|
|
|
function sourceClass(source: OrderSource) {
|
|
switch (source) {
|
|
case 'admin':
|
|
return styles.sourceOperator
|
|
case 'app':
|
|
return styles.sourceApplication
|
|
case 'website':
|
|
default:
|
|
return styles.sourceWebsite
|
|
}
|
|
}
|
|
|
|
function stepColor(
|
|
steps: OrderProcessStep[],
|
|
processStepId: string,
|
|
processStepColor?: string | null,
|
|
) {
|
|
if (processStepColor?.trim()) return processStepColor.trim()
|
|
const index = steps.findIndex((step) => step.id === processStepId)
|
|
const step = index >= 0 ? steps[index] : steps[0]
|
|
if (step?.color) return step.color
|
|
|
|
if (typeof document !== 'undefined') {
|
|
const themed = getComputedStyle(document.documentElement)
|
|
.getPropertyValue('--primary')
|
|
.trim()
|
|
if (themed) return themed
|
|
}
|
|
|
|
return '#3b82f6'
|
|
}
|
|
|
|
export function OrdersPage() {
|
|
const t = useT()
|
|
const { locale } = useLocale()
|
|
const { showToast } = useToast()
|
|
const [data, setData] = useState<OrdersListResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({})
|
|
const [page, setPage] = useState(1)
|
|
|
|
const [draftOrderId, setDraftOrderId] = useState('')
|
|
const [draftCustomer, setDraftCustomer] = useState('')
|
|
const [draftDateFrom, setDraftDateFrom] = useState('')
|
|
const [draftDateTo, setDraftDateTo] = useState('')
|
|
const [draftMinCost, setDraftMinCost] = useState('')
|
|
const [draftMaxCost, setDraftMaxCost] = useState('')
|
|
|
|
const [viewOrder, setViewOrder] = useState<Order | null>(null)
|
|
const [transactionsOrder, setTransactionsOrder] = useState<Order | null>(null)
|
|
const [stepOrder, setStepOrder] = useState<Order | null>(null)
|
|
const [processSteps, setProcessSteps] = useState<OrderProcessStep[]>(DEFAULT_ORDER_PROCESS_STEPS)
|
|
const [removeTarget, setRemoveTarget] = useState<Order | null>(null)
|
|
const [removing, setRemoving] = useState(false)
|
|
|
|
function sourceLabel(source: OrderSource) {
|
|
switch (source) {
|
|
case 'admin':
|
|
return t('orders.source.operator')
|
|
case 'app':
|
|
return t('orders.source.app')
|
|
case 'website':
|
|
default:
|
|
return t('orders.source.website')
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function loadSteps() {
|
|
try {
|
|
const data = await getSettings(controller.signal)
|
|
setProcessSteps(data.settings.store.orderProcessSteps)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
}
|
|
}
|
|
|
|
void loadSteps()
|
|
return () => controller.abort()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function load() {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const result = await listOrders(
|
|
{
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
...appliedFilters,
|
|
},
|
|
controller.signal,
|
|
)
|
|
if (controller.signal.aborted) return
|
|
setData(result)
|
|
} catch (err) {
|
|
if (isAbortError(err) || controller.signal.aborted) return
|
|
setError(err instanceof ApiError ? err.message : t('orders.error.load'))
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
}
|
|
}
|
|
|
|
void load()
|
|
|
|
return () => {
|
|
controller.abort()
|
|
}
|
|
}, [
|
|
page,
|
|
appliedFilters.orderNumber,
|
|
appliedFilters.customerQuery,
|
|
appliedFilters.dateFrom,
|
|
appliedFilters.dateTo,
|
|
appliedFilters.minTotal,
|
|
appliedFilters.maxTotal,
|
|
t,
|
|
])
|
|
|
|
const totalPages = useMemo(() => {
|
|
const total = data?.total ?? 0
|
|
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
}, [data?.total])
|
|
|
|
const showingFrom = useMemo(() => {
|
|
if (!data || data.total === 0) return 0
|
|
return (page - 1) * PAGE_SIZE + 1
|
|
}, [data, page])
|
|
|
|
const showingTo = useMemo(() => {
|
|
if (!data) return 0
|
|
return Math.min(data.total, page * PAGE_SIZE)
|
|
}, [data, page])
|
|
|
|
function applyFilters() {
|
|
const minTotal = parseIrtInput(draftMinCost)
|
|
const maxTotal = parseIrtInput(draftMaxCost)
|
|
|
|
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
|
|
setError(t('orders.error.minMax'))
|
|
return
|
|
}
|
|
|
|
setPage(1)
|
|
setAppliedFilters({
|
|
...(draftOrderId.trim() ? { orderNumber: draftOrderId.trim() } : {}),
|
|
...(draftCustomer.trim() ? { customerQuery: draftCustomer.trim() } : {}),
|
|
...(draftDateFrom ? { dateFrom: draftDateFrom } : {}),
|
|
...(draftDateTo ? { dateTo: draftDateTo } : {}),
|
|
...(minTotal !== null ? { minTotal } : {}),
|
|
...(maxTotal !== null ? { maxTotal } : {}),
|
|
})
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftOrderId('')
|
|
setDraftCustomer('')
|
|
setDraftDateFrom('')
|
|
setDraftDateTo('')
|
|
setDraftMinCost('')
|
|
setDraftMaxCost('')
|
|
setPage(1)
|
|
setAppliedFilters({})
|
|
setError('')
|
|
}
|
|
|
|
function handleStepSaved(updated: Order) {
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
|
|
}
|
|
})
|
|
}
|
|
|
|
async function confirmRemove() {
|
|
if (!removeTarget) return
|
|
setRemoving(true)
|
|
setError('')
|
|
try {
|
|
await removeOrder(removeTarget.id)
|
|
setData((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
total: Math.max(0, prev.total - 1),
|
|
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
|
}
|
|
})
|
|
showToast(t('orders.removed', { orderNumber: removeTarget.orderNumber }), 'success')
|
|
setRemoveTarget(null)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : t('orders.error.remove'))
|
|
} finally {
|
|
setRemoving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Home', href: '/' },
|
|
{ label: 'Store', href: '/store' },
|
|
{ label: 'Orders' },
|
|
]}
|
|
/>
|
|
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<PageTitle en="ORDERS">{t('orders.title')}</PageTitle>
|
|
<p className={pageStyles.pageSubtitle}>{t('orders.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={filterStyles.filtersPanel}>
|
|
<div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
|
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={styles.ordersFiltersRow}>
|
|
<div className={`${filterStyles.field} ${styles.filterOrderId}`}>
|
|
<input
|
|
id="filter-order-id"
|
|
value={draftOrderId}
|
|
onChange={(e) => setDraftOrderId(e.target.value)}
|
|
placeholder={t('orders.filter.orderId')}
|
|
aria-label={t('orders.filter.orderId')}
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
|
|
<input
|
|
id="filter-order-customer"
|
|
value={draftCustomer}
|
|
onChange={(e) => setDraftCustomer(e.target.value)}
|
|
placeholder={t('orders.filter.customer')}
|
|
aria-label={t('orders.filter.customer')}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${styles.filterDate}`}>
|
|
<input
|
|
id="filter-order-date-from"
|
|
type="date"
|
|
value={draftDateFrom}
|
|
onChange={(e) => setDraftDateFrom(e.target.value)}
|
|
aria-label={t('orders.filter.dateFrom')}
|
|
title={t('orders.filter.dateFrom')}
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${styles.filterDate}`}>
|
|
<input
|
|
id="filter-order-date-to"
|
|
type="date"
|
|
value={draftDateTo}
|
|
onChange={(e) => setDraftDateTo(e.target.value)}
|
|
aria-label={t('orders.filter.dateTo')}
|
|
title={t('orders.filter.dateTo')}
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${styles.filterCost}`}>
|
|
<input
|
|
id="filter-order-min-cost"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draftMinCost}
|
|
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
|
|
placeholder={t('orders.filter.minCost')}
|
|
aria-label={t('orders.filter.minCost')}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div className={`${filterStyles.field} ${styles.filterCost}`}>
|
|
<input
|
|
id="filter-order-max-cost"
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draftMaxCost}
|
|
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
|
|
placeholder={t('orders.filter.maxCost')}
|
|
aria-label={t('orders.filter.maxCost')}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={filterStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
|
disabled={loading}
|
|
aria-label={t('orders.search')}
|
|
title={t('orders.search')}
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={loading}
|
|
aria-label={t('orders.clearFilters')}
|
|
title={t('orders.clearFilters')}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className={styles.tablePanel}>
|
|
<div className={styles.tableWrap}>
|
|
<div className={styles.tableHeader}>
|
|
<div className={styles.tableHeaderTitle}>{t('orders.listTitle')}</div>
|
|
<div className={styles.meta}>
|
|
{data ? (
|
|
data.total > 0 ? (
|
|
t('orders.showing', {
|
|
from: showingFrom,
|
|
to: showingTo,
|
|
total: data.total,
|
|
})
|
|
) : (
|
|
t('orders.none')
|
|
)
|
|
) : (
|
|
' '
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className={styles.errorBanner}>{error}</div>}
|
|
|
|
<table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
|
|
<colgroup>
|
|
<col className={styles.colOrderId} />
|
|
<col className={styles.colCustomer} />
|
|
<col className={styles.colItems} />
|
|
<col className={styles.colTotal} />
|
|
<col className={styles.colDate} />
|
|
<col className={styles.colStep} />
|
|
<col className={styles.colSource} />
|
|
<col className={styles.colActions} />
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.th}>{t('orders.col.orderId')}</th>
|
|
<th className={styles.th}>{t('orders.col.customer')}</th>
|
|
<th className={styles.th}>{t('orders.col.items')}</th>
|
|
<th className={styles.th}>{t('orders.col.total')}</th>
|
|
<th className={styles.th}>{t('orders.col.date')}</th>
|
|
<th className={styles.th}>{t('orders.col.step')}</th>
|
|
<th className={styles.th}>{t('orders.col.source')}</th>
|
|
<th className={`${styles.th} ${styles.thActions}`}>{t('orders.col.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && (
|
|
<tr>
|
|
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
|
{t('orders.loading')}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
|
|
{!loading && data?.items?.length === 0 && (
|
|
<EmptyTableRow
|
|
colSpan={COLUMN_COUNT}
|
|
icon={ShoppingBag}
|
|
message={t('orders.empty')}
|
|
/>
|
|
)}
|
|
|
|
{!loading &&
|
|
data?.items?.map((order) => {
|
|
const { date, time } = formatDateTime(order.createdAt, locale)
|
|
const itemQty = totalItemQuantity(order)
|
|
const currentStepLabel = stepLabel(
|
|
processSteps,
|
|
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
|
order.processStepLabel,
|
|
order.processStepLabelFa,
|
|
locale,
|
|
)
|
|
|
|
return (
|
|
<tr key={order.id}>
|
|
<td className={styles.td}>
|
|
<div className={styles.orderNumber}>{order.orderNumber}</div>
|
|
</td>
|
|
<td className={styles.td}>
|
|
<div className={styles.customerName}>{displayName(order)}</div>
|
|
<div className={styles.customerCell}>
|
|
{formatCellForDisplay(order.customer.cellNumber)}
|
|
</div>
|
|
</td>
|
|
<td className={styles.td}>
|
|
{itemQty > 0 ? itemQty : <span className={styles.subText}>0</span>}
|
|
</td>
|
|
<td className={styles.td}>{formatIrtPrice(order.total)}</td>
|
|
<td className={`${styles.td} ${styles.dateCell}`}>
|
|
<div className={styles.dateTime}>{date}</div>
|
|
{time && <div className={styles.dateTimeSub}>{time}</div>}
|
|
</td>
|
|
<td className={styles.td}>
|
|
<button
|
|
type="button"
|
|
className={styles.stepBadge}
|
|
style={stepBadgeStyle(
|
|
stepColor(
|
|
processSteps,
|
|
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
|
order.processStepColor,
|
|
),
|
|
)}
|
|
onClick={() => setStepOrder(order)}
|
|
aria-label={t('orders.changeStep', { step: currentStepLabel })}
|
|
>
|
|
{currentStepLabel}
|
|
</button>
|
|
</td>
|
|
<td className={styles.td}>
|
|
<span className={`${styles.sourceBadge} ${sourceClass(order.source)}`}>
|
|
{sourceLabel(order.source)}
|
|
</span>
|
|
</td>
|
|
<td className={`${styles.td} ${styles.tdActions}`}>
|
|
<div className={styles.rowActions}>
|
|
<Tooltip label={t('orders.viewItems')}>
|
|
<button
|
|
type="button"
|
|
className={styles.actionBtn}
|
|
onClick={() => setViewOrder(order)}
|
|
aria-label={t('orders.viewItems')}
|
|
>
|
|
<Eye size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('orders.transactions')}>
|
|
<button
|
|
type="button"
|
|
className={styles.actionBtn}
|
|
onClick={() => setTransactionsOrder(order)}
|
|
aria-label={t('orders.transactions')}
|
|
>
|
|
<CreditCard size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip label={t('orders.remove')}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
|
onClick={() => setRemoveTarget(order)}
|
|
aria-label={t('orders.remove')}
|
|
disabled={removing}
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className={styles.pagination}>
|
|
<div>
|
|
{t('orders.pageMeta', {
|
|
page,
|
|
totalPages,
|
|
pageSize: PAGE_SIZE,
|
|
total: data?.total ?? 0,
|
|
})}
|
|
</div>
|
|
<Pagination
|
|
currentPage={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
disabled={loading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<OrderItemsModal
|
|
open={viewOrder !== null}
|
|
order={viewOrder}
|
|
onClose={() => setViewOrder(null)}
|
|
/>
|
|
|
|
<OrderTransactionsModal
|
|
open={transactionsOrder !== null}
|
|
order={transactionsOrder}
|
|
onClose={() => setTransactionsOrder(null)}
|
|
/>
|
|
|
|
<OrderStepModal
|
|
open={stepOrder !== null}
|
|
order={stepOrder}
|
|
steps={processSteps}
|
|
onClose={() => setStepOrder(null)}
|
|
onSaved={handleStepSaved}
|
|
/>
|
|
|
|
<ConfirmDeleteModal
|
|
open={removeTarget !== null}
|
|
title={t('orders.deleteTitle')}
|
|
message={
|
|
removeTarget
|
|
? t('orders.deleteMessage', { orderNumber: removeTarget.orderNumber })
|
|
: ''
|
|
}
|
|
onCancel={() => setRemoveTarget(null)}
|
|
onConfirm={() => void confirmRemove()}
|
|
/>
|
|
</main>
|
|
)
|
|
}
|