store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -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>
);
}