store user login
This commit is contained in:
@@ -4,7 +4,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Bike, MapPin, Package, Receipt, ShoppingCart, Truck } from 'lucide-react';
|
||||
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
syncLagMs,
|
||||
type StatusKey,
|
||||
} from '../orderStatus';
|
||||
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
|
||||
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
|
||||
import { shortAge } from '../posStatus';
|
||||
|
||||
@@ -52,7 +53,8 @@ type Tab = 'orders' | 'deliveries' | 'counter';
|
||||
* every row would be a column of dashes.
|
||||
*/
|
||||
export function SalesPage() {
|
||||
const { scoped, selected, current, branches, tenantid } = useBranchScope();
|
||||
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('orders');
|
||||
const [status, setStatus] = useState<StatusKey>('all');
|
||||
const [preset, setPreset] = useState<RangePreset>('month');
|
||||
@@ -161,14 +163,25 @@ export function SalesPage() {
|
||||
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
|
||||
isLive
|
||||
actions={
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
<HStack gap={0.5} align="center" wrap="wrap">
|
||||
{/* Counter sales that never touched a till. Sits beside the date
|
||||
range rather than under the Counter tab because it is how the
|
||||
figures on every tab get there for a shop with no POS. */}
|
||||
<BarAction
|
||||
label="Upload counter sales"
|
||||
icon={<Upload size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
range={range}
|
||||
onChange={(nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
@@ -239,9 +252,10 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Cash to collect"
|
||||
value={orderTotals.cod > 0 ? money(orderTotals.cod) : '—'}
|
||||
value={money(orderTotals.cod)}
|
||||
note="COD on these orders"
|
||||
tone={orderTotals.cod > 0 ? 'warning' : 'neutral'}
|
||||
icon={<Banknote size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Items"
|
||||
@@ -252,16 +266,16 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average order"
|
||||
value={orderRows.length > 0 ? money(orderTotals.value / orderRows.length) : '—'}
|
||||
value={money(orderRows.length > 0 ? orderTotals.value / orderRows.length : 0)}
|
||||
note="in this view"
|
||||
tone="neutral"
|
||||
icon={<Calculator size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Orders"
|
||||
note="newest first"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
|
||||
/>
|
||||
<OrdersTable
|
||||
@@ -301,12 +315,14 @@ export function SalesPage() {
|
||||
value={count(deliveryTotals.unassigned)}
|
||||
note="no rider yet"
|
||||
tone={deliveryTotals.unassigned > 0 ? 'warning' : 'success'}
|
||||
icon={<UserX size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Job value"
|
||||
value={money(deliveryTotals.value)}
|
||||
note="what these deliveries carry"
|
||||
tone="neutral"
|
||||
icon={<IndianRupee size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Rider charges"
|
||||
@@ -320,7 +336,6 @@ export function SalesPage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Deliveries"
|
||||
note="planned against actual"
|
||||
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
|
||||
/>
|
||||
<DeliveriesTable
|
||||
@@ -355,17 +370,17 @@ export function SalesPage() {
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average bill"
|
||||
value={
|
||||
counterTotals.bills > 0 ? money(counterTotals.gross / counterTotals.bills) : '—'
|
||||
}
|
||||
value={money(counterTotals.bills > 0 ? counterTotals.gross / counterTotals.bills : 0)}
|
||||
note="per transaction"
|
||||
tone="neutral"
|
||||
icon={<Calculator size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Tax collected"
|
||||
value={money(counterTotals.tax)}
|
||||
note="on counter sales"
|
||||
tone="neutral"
|
||||
icon={<Percent size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Bills listed"
|
||||
@@ -376,13 +391,13 @@ export function SalesPage() {
|
||||
: 'all of them'
|
||||
}
|
||||
tone="neutral"
|
||||
icon={<ReceiptText size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Counter bills"
|
||||
note="as the tills reported them"
|
||||
action={
|
||||
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
|
||||
}
|
||||
@@ -396,6 +411,15 @@ export function SalesPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isUploading && tenantid ? (
|
||||
<CounterSalesDrawer
|
||||
tenantid={tenantid}
|
||||
{...(isPinned && current ? { locationid: current.locationid } : {})}
|
||||
branchIds={branches.map((branch) => branch.locationid)}
|
||||
onClose={() => setUploading(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
|
||||
) : null}
|
||||
@@ -433,16 +457,16 @@ function OrdersTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="right">#</Th>
|
||||
<Th align="left">Order</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Pickup</Th>
|
||||
<Th align="left">Drop</Th>
|
||||
<Th align="right">Qty</Th>
|
||||
<Th align="right">COD</Th>
|
||||
<Th align="right">Amount</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="right" />
|
||||
<Th>#</Th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Pickup</Th>
|
||||
<Th>Drop</Th>
|
||||
<Th>Qty</Th>
|
||||
<Th>COD</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -453,17 +477,17 @@ function OrdersTable({
|
||||
const amount = orderValue(row);
|
||||
return (
|
||||
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{index + 1}</Mono>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.orderdate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -482,29 +506,29 @@ function OrdersTable({
|
||||
) : null}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>{row.pickupcustomer || branchLabel(row.locationname)}</Primary>
|
||||
<Secondary>{row.pickupsuburb || row.pickupaddress}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>{row.deliverycustomer || '—'}</Primary>
|
||||
<Secondary>{row.deliverysuburb || row.deliveryaddress}</Secondary>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{orderQuantity(row) || '—'}</Mono>
|
||||
</Td>
|
||||
{/* Only rendered when there is cash to collect — a COD column
|
||||
of dashes is a column that costs width and says nothing. */}
|
||||
<Td align="right" isStrong={cod > 0} isMuted={cod === 0}>
|
||||
<Td isStrong={cod > 0} isMuted={cod === 0}>
|
||||
<Mono>{cod > 0 ? moneyExact(cod) : '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isStrong={amount > 0} isMuted={amount === 0}>
|
||||
<Td isStrong={amount > 0} isMuted={amount === 0}>
|
||||
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} />
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<ViewHint />
|
||||
</Td>
|
||||
</Row>
|
||||
@@ -547,15 +571,15 @@ function DeliveriesTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Order</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Pickup</Th>
|
||||
<Th align="left">Drop</Th>
|
||||
<Th align="left">Rider</Th>
|
||||
<Th align="right">KM</Th>
|
||||
<Th align="right">Charge / value</Th>
|
||||
<Th align="left">Status</Th>
|
||||
<Th align="left">Notes</Th>
|
||||
<Th>Order</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Pickup</Th>
|
||||
<Th>Drop</Th>
|
||||
<Th>Rider</Th>
|
||||
<Th>KM</Th>
|
||||
<Th>Charge / value</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Notes</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -579,26 +603,26 @@ function DeliveriesTable({
|
||||
|
||||
return (
|
||||
<Row key={row.deliveryid} onClick={() => onOpen(row)}>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
|
||||
</Primary>
|
||||
<Secondary>{stamp(row.assigntime || row.deliverydate)}</Secondary>
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(row.locationname)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary w={124}>{pickupName || pickupAddr || '—'}</Primary>
|
||||
<Secondary w={124}>{pickupName ? pickupAddr : ''}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary w={124}>{dropName || dropAddr || '—'}</Primary>
|
||||
<Secondary w={124}>{dropName ? dropAddr : ''}</Secondary>
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
{row.ridername ? (
|
||||
<span
|
||||
style={{
|
||||
@@ -642,24 +666,24 @@ function DeliveriesTable({
|
||||
{/* Planned above, actual below — the pair is the point. A
|
||||
delivery that was quoted 3km and rode 9 is the row worth
|
||||
finding, and one number cannot show it. */}
|
||||
<Td align="right">
|
||||
<Td>
|
||||
<Pill color="#ef4444">{planned ? planned.toFixed(1) : '—'}</Pill>
|
||||
{actual > 0 ? <Pill color="#10b981">{actual.toFixed(1)}</Pill> : null}
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Td>
|
||||
{charge > 0 ? <Pill color="#ef4444">{moneyExact(charge)}</Pill> : null}
|
||||
{value > 0 ? <Pill color="#10b981">{moneyExact(value)}</Pill> : null}
|
||||
{charge === 0 && value === 0 ? (
|
||||
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
|
||||
) : null}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip
|
||||
label={row.orderstatus}
|
||||
color={statusColor(DELIVERY_STATUS, row.orderstatus)}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
<span
|
||||
title={notes}
|
||||
style={{
|
||||
@@ -711,16 +735,16 @@ function BillsTable({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th align="left">Invoice</Th>
|
||||
<Th align="left">Time</Th>
|
||||
{showBranch ? <Th align="left">Branch</Th> : null}
|
||||
<Th align="left">Till</Th>
|
||||
<Th align="left">Cashier</Th>
|
||||
<Th align="left">Customer</Th>
|
||||
<Th align="left">Paid by</Th>
|
||||
<Th align="right">Items</Th>
|
||||
<Th align="right">Tax</Th>
|
||||
<Th align="right">Total</Th>
|
||||
<Th>Invoice</Th>
|
||||
<Th>Time</Th>
|
||||
{showBranch ? <Th>Branch</Th> : null}
|
||||
<Th>Till</Th>
|
||||
<Th>Cashier</Th>
|
||||
<Th>Customer</Th>
|
||||
<Th>Paid by</Th>
|
||||
<Th>Items</Th>
|
||||
<Th>Tax</Th>
|
||||
<Th>Total</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -731,7 +755,7 @@ function BillsTable({
|
||||
const lag = syncLagMs(bill);
|
||||
return (
|
||||
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Primary>
|
||||
<Mono>{bill.invoicenumber || `#${bill.posorderid}`}</Mono>
|
||||
</Primary>
|
||||
@@ -748,36 +772,36 @@ function BillsTable({
|
||||
) : null}
|
||||
</Secondary>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{clockOf(bill.billedat)}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{branchLabel(branch)}
|
||||
</Td>
|
||||
) : null}
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{bill.terminalid || '—'}</Mono>
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{bill.cashiername || '—'}
|
||||
</Td>
|
||||
<Td align="left" isMuted>
|
||||
<Td isMuted>
|
||||
{bill.customername || bill.customermobile || 'Walk-in'}
|
||||
</Td>
|
||||
<Td align="left">
|
||||
<Td>
|
||||
<Chip
|
||||
label={bill.paymentmode || 'n/a'}
|
||||
color={bill.paymentmode ? 'var(--color-brand)' : 'var(--color-ink-4)'}
|
||||
/>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{count(bill.itemcount)}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isMuted>
|
||||
<Td isMuted>
|
||||
<Mono>{moneyExact(bill.taxamount)}</Mono>
|
||||
</Td>
|
||||
<Td align="right" isStrong>
|
||||
<Td isStrong>
|
||||
<Mono>{moneyExact(bill.total)}</Mono>
|
||||
</Td>
|
||||
</tr>
|
||||
@@ -864,7 +888,8 @@ function Pill({ color, children }: { color: string; children: React.ReactNode })
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
// Centred with the rest of the table, not pushed to the cell's edge.
|
||||
margin: '0 auto',
|
||||
width: 'fit-content',
|
||||
minWidth: 62,
|
||||
textAlign: 'center',
|
||||
@@ -1130,11 +1155,11 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
|
||||
function Th({ children }: { children?: React.ReactNode; }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
@@ -1150,19 +1175,17 @@ function Th({ children, align }: { children?: React.ReactNode; align: 'left' | '
|
||||
|
||||
function Td({
|
||||
children,
|
||||
align,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
align: 'left' | 'right';
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: align,
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
@@ -1174,3 +1197,49 @@ function Td({
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A header action drawn like a tab, so the two rows read as one bar. Same shape
|
||||
* as Inventory's — copied rather than shared because the two pages own their
|
||||
* chrome, and a shared one would have to take a theme prop to stay identical.
|
||||
*/
|
||||
function BarAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
|
||||
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background .2s, color .2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user