Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderStepModal } from '../components/OrderStepModal'
|
||||
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
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 buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(order: Order) {
|
||||
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
function totalItemQuantity(order: Order) {
|
||||
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
}
|
||||
|
||||
function sourceLabel(source: OrderSource) {
|
||||
switch (source) {
|
||||
case 'admin':
|
||||
return 'Operator'
|
||||
case 'app':
|
||||
return 'Application'
|
||||
case 'website':
|
||||
default:
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceClass(source: OrderSource) {
|
||||
switch (source) {
|
||||
case 'admin':
|
||||
return styles.sourceOperator
|
||||
case 'app':
|
||||
return styles.sourceApplication
|
||||
case 'website':
|
||||
default:
|
||||
return styles.sourceWebsite
|
||||
}
|
||||
}
|
||||
|
||||
function stepLabel(
|
||||
steps: OrderProcessStep[],
|
||||
processStepId: string,
|
||||
processStepLabel?: string | null,
|
||||
) {
|
||||
if (processStepLabel?.trim()) return processStepLabel.trim()
|
||||
return steps.find((step) => step.id === processStepId)?.label ?? processStepId
|
||||
}
|
||||
|
||||
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 { 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)
|
||||
|
||||
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 : 'Unable to load orders.')
|
||||
} 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,
|
||||
])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
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('Minimum cost cannot be greater than maximum cost.')
|
||||
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(`Order ${removeTarget.orderNumber} has been removed.`, 'success')
|
||||
setRemoveTarget(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove order.')
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Store', href: '/store' },
|
||||
{ label: 'Orders' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Orders</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Track customer orders placed through your website, application, or by operators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersGrid}>
|
||||
<div className={styles.ordersFiltersRow}>
|
||||
<div className={`${filterStyles.field} ${styles.filterOrderId}`}>
|
||||
<label htmlFor="filter-order-id">Order ID</label>
|
||||
<input
|
||||
id="filter-order-id"
|
||||
value={draftOrderId}
|
||||
onChange={(e) => setDraftOrderId(e.target.value)}
|
||||
placeholder="ORD-..."
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
|
||||
<label htmlFor="filter-order-customer">Customer name or number</label>
|
||||
<input
|
||||
id="filter-order-customer"
|
||||
value={draftCustomer}
|
||||
onChange={(e) => setDraftCustomer(e.target.value)}
|
||||
placeholder="Name or phone"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterDate}`}>
|
||||
<label htmlFor="filter-order-date-from">Date from</label>
|
||||
<input
|
||||
id="filter-order-date-from"
|
||||
type="date"
|
||||
value={draftDateFrom}
|
||||
onChange={(e) => setDraftDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterDate}`}>
|
||||
<label htmlFor="filter-order-date-to">Date to</label>
|
||||
<input
|
||||
id="filter-order-date-to"
|
||||
type="date"
|
||||
value={draftDateTo}
|
||||
onChange={(e) => setDraftDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterCost}`}>
|
||||
<label htmlFor="filter-order-min-cost">Min cost (IRT)</label>
|
||||
<input
|
||||
id="filter-order-min-cost"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draftMinCost}
|
||||
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
|
||||
placeholder="0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${filterStyles.field} ${styles.filterCost}`}>
|
||||
<label htmlFor="filter-order-max-cost">Max cost (IRT)</label>
|
||||
<input
|
||||
id="filter-order-max-cost"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draftMaxCost}
|
||||
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
|
||||
placeholder="0"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={filterStyles.filterActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Order list</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
) : (
|
||||
'No orders'
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<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}>Order ID</th>
|
||||
<th className={styles.th}>Customer</th>
|
||||
<th className={styles.th}>Items</th>
|
||||
<th className={styles.th}>Total cost</th>
|
||||
<th className={styles.th}>Date & time</th>
|
||||
<th className={styles.th}>Step</th>
|
||||
<th className={styles.th}>Registered by</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((order) => {
|
||||
const { date, time } = formatDateTime(order.createdAt)
|
||||
const itemQty = totalItemQuantity(order)
|
||||
|
||||
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={`Change step: ${stepLabel(
|
||||
processSteps,
|
||||
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
||||
order.processStepLabel,
|
||||
)}`}
|
||||
>
|
||||
{stepLabel(
|
||||
processSteps,
|
||||
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
||||
order.processStepLabel,
|
||||
)}
|
||||
</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="View items">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setViewOrder(order)}
|
||||
aria-label="View items"
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Transaction details">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setTransactionsOrder(order)}
|
||||
aria-label="Transaction details"
|
||||
>
|
||||
<CreditCard size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove order">
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(order)}
|
||||
aria-label="Remove order"
|
||||
disabled={removing}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</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="Remove order?"
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove order ${removeTarget.orderNumber}? This cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user