updates on the ui changes and table

This commit is contained in:
2026-08-17 15:39:46 +05:30
parent a46212f291
commit 809d046323
38 changed files with 6403 additions and 7019 deletions

View File

@@ -17,6 +17,7 @@ These components are imported across every page. Changes here have **fan-out imp
| Component | Use | Status |
|---|---|---|
| `PageLayout.js` | **The page design language**: `PageShell`, `KpiGrid`, `FilterToolbar`, `DataCard` | ✅ **Astryx** — use these for every operator page (see §2b) |
| `PageHeader.js` | Page title + live subtitle + action slot | ✅ **Astryx** — `Heading`/`Text`/`HStack`/`StatusDot` |
| `StatCard.js` | KPI / metric tile | ✅ **Astryx** — `Card`/`Text`/`Center`/`Skeleton` |
| `StatusBadge.js` | Per-row lifecycle badge | ✅ **Astryx** — `Badge` driven by `themes/dt/status.js` |
@@ -47,6 +48,51 @@ The design system is documented in the root `CLAUDE.md` §6. Read it before edit
---
## 2b. Page composition — use `PageLayout.js`, don't hand-roll spacing
Every operator page composes from the same four primitives. **Do not set
`marginTop`, `gap` or `padding` on page sections by hand** — that is exactly how
23 pages ended up with 23 different rhythms and the UI read as cramped and
inconsistent.
```jsx
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
<PageShell> {/* gap={6} between regions */}
<PageHeader title="…" subtitle="…" live action={<Button …/>} />
<KpiGrid>…<StatCard/>…</KpiGrid> {/* fills the row, no dead gap */}
<DataCard
toolbar={<FilterToolbar>…selectors, date, search…</FilterToolbar>}
tabs={<StatusTabs …/>}
>
…TableScroll + Table…
</DataCard>
</PageShell>
```
Values come from the Astryx `dashboard` page template (`padding={6}` /
`VStack gap={6}` / `Grid gap={4}`), not from taste.
**Three Astryx traps these primitives exist to absorb** — all three fail
*silently*, with no lint or build error:
- **`Grid` has no `padding` prop.** It declares only `columns`/`gap`/`rowGap`/
`columnGap`/`align`/`justify`/`width`/`height`. A `padding` you pass is spread
onto the DOM and does nothing, so controls sit flush against the card edge.
`FilterToolbar` puts the padding on a `VStack` wrapper instead.
- **`Grid`'s `repeat` defaults to `'fill'`, which preserves empty tracks.** In a
wide container that lays out more tracks than you have cards, so they bunch
left and leave a dead gap. `KpiGrid` always passes `repeat: 'fit'`.
- **Page-level actions belong in `PageHeader`; filters do not.**
`LocationAutocomplete` carries its own width and ignores a wrapper's, so
putting it in the header made it overlap its neighbour.
Before using any Astryx prop, check it is actually declared —
`grep "export declare function <Name>" node_modules/@astryxdesign/core/dist/<Name>/<Name>.d.ts`.
An undeclared prop is spread to the DOM and fails invisibly.
---
## 3. `LocationAutocomplete` — the zone picker
```jsx

View File

@@ -0,0 +1,190 @@
import PropTypes from 'prop-types';
import { Grid } from '@astryxdesign/core/Grid';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Divider } from '@astryxdesign/core/Divider';
// ==============================|| PAGE LAYOUT PRIMITIVES ||============================== //
//
// The operator-page design language, in one place.
//
// Every page had been composing its own rhythm out of ad-hoc `marginTop: 16`,
// `gap={2}` and `gap={3}` values, so no two pages agreed on spacing and the
// result read as cramped. These four primitives fix the vertical rhythm, the
// grid behaviour and the card chrome so a page only decides WHAT goes in each
// slot, never how far apart things sit.
//
// The values come from the Astryx `dashboard` page template (which uses
// LayoutContent padding={6} + VStack gap={6} + Grid gap={4}) — i.e. they are the
// design system's own numbers, not invented here.
//
// <PageShell>
// <PageHeader … />
// <KpiGrid>…StatCard…</KpiGrid>
// <DataCard toolbar={<FilterToolbar>…</FilterToolbar>} tabs={<StatusTabs …/>}>
// …table…
// </DataCard>
// </PageShell>
// ---------------------------------------------------------------------------
// PageShell — owns the vertical rhythm between a page's major regions.
// gap={6} is the template's section spacing. Use this INSTEAD of putting
// `style={{ marginTop: N }}` on individual sections.
//
// `fill` opts a page into FULL-HEIGHT mode: the header (and any KPI row) take
// only the height they need and the last region — a `<DataCard fill>` — grows
// to the bottom of the viewport, with its table scrolling inside itself.
//
// Why the `:has()` rule rather than a `height: 100%` chain or a `100vh` calc:
//
// AppShell (height="fill") gives its scroll container `.astryx-layout-content`
// a definite `height: 100%` — so a real flex chain IS available. The chain
// breaks at exactly one link: MainLayout's `.main-content-area`, the div that
// holds the page padding, sets `min-height: 100%` and NOT `height`, so its own
// height is auto. A percentage height on any descendant then resolves against
// `auto` and is dropped, which is why full-height pages have had to guess with
// `calc(100vh - 260px)` — a number that is wrong at every viewport the guess
// wasn't written for, and that leaves dead space under the table.
//
// `:has(> .dt-page-fill)` re-definites that one link, and ONLY while a page
// that asked for fill mode is mounted — every other page keeps the auto-height,
// grow-with-content behaviour it has today. Scoping it this way is what makes
// this safe to add to a shared primitive. Astryx's own stylesheet already
// relies on `:has()`, so it is not a new browser requirement; where it is
// unsupported the page simply falls back to today's auto-height layout.
// ---------------------------------------------------------------------------
const FILL_CLASS = 'dt-page-fill';
export function PageShell({ children, gap = 6, fill = false }) {
return (
<>
{fill && (
<style>{`
.main-content-area:has(> .${FILL_CLASS}) {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
`}</style>
)}
<VStack gap={gap} padding={0} className={fill ? FILL_CLASS : undefined} style={fill ? { flex: 1, minHeight: 0 } : undefined}>
{children}
</VStack>
</>
);
}
PageShell.propTypes = {
children: PropTypes.node,
gap: PropTypes.number,
fill: PropTypes.bool
};
// ---------------------------------------------------------------------------
// KpiGrid — the row of StatCards under the page header.
//
// `repeat: 'fit'` is load-bearing and the reason this exists as a shared
// primitive: Grid's default is `'fill'`, which PRESERVES empty tracks. With a
// small minWidth in a wide container that lays out more tracks than there are
// cards, so the cards bunch to the left and leave a dead gap on the right —
// the single most visible layout bug on these pages. `'fit'` collapses the
// empty tracks so the cards stretch to fill the row.
//
// `max` caps the column count so 3 cards don't each become 400px wide on an
// ultrawide monitor; it defaults to the number of children.
// ---------------------------------------------------------------------------
export function KpiGrid({ children, minWidth = 240, max }) {
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
return (
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={4}>
{children}
</Grid>
);
}
KpiGrid.propTypes = {
children: PropTypes.node,
minWidth: PropTypes.number,
max: PropTypes.number
};
// ---------------------------------------------------------------------------
// FilterToolbar — the filter/search controls above a table.
//
// NOTE the padding lives on the VStack, not the Grid. `Grid` has no `padding`
// prop (it declares only columns/gap/rowGap/columnGap/align/justify/width/
// height), so passing one spreads it onto the DOM where it silently does
// nothing — controls then sit flush against the card edge with no breathing
// room. That bug is the reason this wrapper exists rather than each page
// hand-rolling the pair.
//
// max 3 keeps a 6-control toolbar as two even rows of three instead of leaving
// an orphan on a second row.
// ---------------------------------------------------------------------------
export function FilterToolbar({ children, minWidth = 220, max = 3, padding = 4 }) {
return (
<VStack padding={padding} gap={0}>
<Grid columns={{ minWidth, max, repeat: 'fit' }} gap={4}>
{children}
</Grid>
</VStack>
);
}
FilterToolbar.propTypes = {
children: PropTypes.node,
minWidth: PropTypes.number,
max: PropTypes.number,
padding: PropTypes.number
};
// ---------------------------------------------------------------------------
// DataCard — one card holding a table and everything that acts on it.
//
// Pages used to stack three separate Cards (filters, then tabs, then table),
// which read as loose boxes with no relationship to each other. Grouping them
// into a single card with dividers makes it obvious that the filters and tabs
// act on the table below them.
//
// `tabs` gets its own padded row so the tab strip never sits flush against the
// card edge or against the toolbar above it.
//
// `fill` (pairs with `<PageShell fill>`) makes the card a flex column that
// claims the leftover viewport height: the toolbar and tab rows keep their
// intrinsic height, and the content slot takes the rest with `min-height: 0`
// so its scroll area can actually shrink rather than pushing the card past the
// bottom of the screen. A table inside then only needs `maxHeight="100%"` —
// no viewport arithmetic, and it re-measures on every resize for free.
// ---------------------------------------------------------------------------
export function DataCard({ toolbar, tabs, children, fill = false }) {
return (
<Card
padding={0}
elevation="none"
style={fill ? { overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 } : { overflow: 'hidden' }}
>
{toolbar}
{toolbar && <Divider />}
{tabs && (
<>
<VStack gap={0} paddingInline={4} paddingBlock={2} style={{ overflowX: 'auto' }}>
{tabs}
</VStack>
<Divider />
</>
)}
<VStack gap={0} padding={0} style={fill ? { flex: 1, minHeight: 0 } : undefined}>
{children}
</VStack>
</Card>
);
}
DataCard.propTypes = {
toolbar: PropTypes.node,
tabs: PropTypes.node,
children: PropTypes.node,
fill: PropTypes.bool
};

View File

@@ -27,14 +27,18 @@ export const ThemeDirection = {
const config = {
/**
* The props used for the theme font-style.
* We provide static below options -
* `'Inter', sans-serif`
* `'Poppins', sans-serif`
* `'Roboto', sans-serif`
* `'Public Sans', sans-serif` (default)
* Body font for the MUI theme, which is still mounted for the pages not yet
* converted to Astryx.
*
* Must match `themes/astryx.js`'s `typography.body.family` — otherwise a
* converted page and an unconverted one render in different fonts, which is
* very visible while the migration is in progress. Was `'Public Sans'`.
*
* Poppins / Roboto used to be listed here as alternatives; nothing ever used
* them and they are no longer loaded in public/index.html, so they have been
* dropped rather than left as dead options.
*/
fontFamily: `'Public Sans', sans-serif`,
fontFamily: `'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif`,
/**
* The props used for display menu-items with multi-language.

View File

@@ -6,19 +6,32 @@
that reset doesn't: crisp font rendering and a thin, unobtrusive scrollbar. */
body {
/* Astryx sets --font-family-body on :root (from themes/astryx.js's
typography config) but nothing was applying it to the document, and
MUI's <CssBaseline> — which used to do this — is no longer mounted.
The result was a body computing to "Times New Roman": Astryx components
rendered correctly because they set their own font, but every plain
element and any text outside a <Text> inherited the browser serif
default. Verified in the browser on /login before and after. */
font-family: var(--font-family-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Scrollbars are chrome, not content — they should recede until used.
The thumb was rgba(0,0,0,0.26) at 10px, which drew a solid dark bar down the
right edge of every scrollable table and read as a heavy border rather than
as a scrollbar. Lightened and narrowed; it still darkens on hover so it stays
findable. */
* {
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgba(0, 0, 0, 0.28) transparent;
scrollbar-color: rgba(0, 0, 0, 0.16) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
@@ -26,14 +39,14 @@ body {
}
*::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.26);
background-color: rgba(0, 0, 0, 0.16);
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.45);
background-color: rgba(0, 0, 0, 0.34);
}
*::-webkit-scrollbar-corner {

View File

@@ -6,7 +6,6 @@ import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -22,6 +21,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
@@ -136,36 +136,40 @@ const AppUsers = () => {
<>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
<PageHeader
title="App Users"
subtitle="Live · Staff Logins"
live
action={<Button label="New User" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<PageShell>
<PageHeader
title="App Users"
subtitle="Live · Staff Logins"
live
action={<Button label="New User" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
</Grid>
<KpiGrid>
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{users.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search staff (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack vAlign="center">
<Text weight="bold">
{users.length} total · {rows.length} shown
</Text>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search staff (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
<TableScroll minWidth={880}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
@@ -228,8 +232,8 @@ const AppUsers = () => {
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<Layout
@@ -259,7 +263,7 @@ const AppUsers = () => {
isRequired={!form.id}
isOptional={!!form.id}
/>
<Grid columns={{ minWidth: 160 }} gap={3}>
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={3}>
<Selector label="Role" options={ROLE_OPTIONS} value={form.role} onChange={set('role')} />
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
</Grid>

View File

@@ -40,6 +40,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { getallpricing, getTenants, fetchAppLocations } from 'pages/api/api';
import { createAdminPricing, updateAdminPricing, deleteAdminPricing, simulatePricing } from 'pages/api/doormileApi';
@@ -115,7 +116,10 @@ const ClientsPricing = () => {
[locations]
);
const locationOptions = useMemo(() => (locations || []).filter((l) => l.applocationid), [locations]);
const tenantSelectOptions = useMemo(() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })), [tenants]);
const tenantSelectOptions = useMemo(
() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })),
[tenants]
);
const locationSelectOptions = useMemo(
() => locationOptions.map((l) => ({ value: String(l.applocationid), label: l.locationname || `Zone #${l.applocationid}` })),
[locationOptions]
@@ -123,9 +127,7 @@ const ClientsPricing = () => {
const tenantName = (row) => row.tenantname || tenantMap.get(row.tenantid)?.tenantname || (row.tenantid ? `Tenant #${row.tenantid}` : '—');
const zoneName = (row) =>
row.applocation ||
locationMap.get(String(row.applocationid))?.locationname ||
(row.applocationid ? `Zone #${row.applocationid}` : '—');
row.applocation || locationMap.get(String(row.applocationid))?.locationname || (row.applocationid ? `Zone #${row.applocationid}` : '—');
// getallpricing() takes no zone arg (see api.js) — the LocationAutocomplete
// only ever changed the header subtitle text, not which rows were shown.
@@ -136,10 +138,7 @@ const ClientsPricing = () => {
// from) are two independently-serialized resources with no guarantee they
// agree on number-vs-string; a raw === silently zeroed out every row
// whenever they didn't match, making the table look empty for any zone.
const zonePricing = useMemo(
() => (appId ? pricing.filter((r) => String(r.applocationid) === String(appId)) : pricing),
[pricing, appId]
);
const zonePricing = useMemo(() => (appId ? pricing.filter((r) => String(r.applocationid) === String(appId)) : pricing), [pricing, appId]);
const rows = useMemo(() => {
if (!debouncedSearch) return zonePricing;
@@ -286,54 +285,56 @@ const ClientsPricing = () => {
<>
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
<PageHeader
title="Pricing"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<HStack gap={1.5} vAlign="center" wrap="wrap">
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} placeholder="Select Zone" />
<Button label="Quote Price" variant="secondary" icon={<MdCalculate size={16} />} onClick={() => setQuoteOpen(true)} />
<Button label="New Pricing Rule" variant="primary" icon={<MdAdd size={16} />} onClick={openCreateDialog} />
</HStack>
}
/>
<Grid columns={{ minWidth: 180 }} gap={3}>
{KPI_META.map((item) => {
const Icon = item.icon;
return <StatCard key={item.key} title={item.label} value={item.value} icon={<Icon size={20} />} color={item.color} />;
})}
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdLocalOffer size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Pricing Catalog
</Text>
<Text weight="bold">
{pricing.length} total · {rows.length} shown
</Text>
</VStack>
<PageShell>
<PageHeader
title="Pricing"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<HStack gap={1.5} vAlign="center" wrap="wrap">
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} placeholder="Select Zone" />
<Button label="Quote Price" variant="secondary" icon={<MdCalculate size={16} />} onClick={() => setQuoteOpen(true)} />
<Button label="New Pricing Rule" variant="primary" icon={<MdAdd size={16} />} onClick={openCreateDialog} />
</HStack>
<div style={{ width: 300, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search pricing (ctrl+k)"
/>
</div>
</HStack>
</Card>
}
/>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<KpiGrid>
{KPI_META.map((item) => {
const Icon = item.icon;
return <StatCard key={item.key} title={item.label} value={item.value} icon={<Icon size={20} />} color={item.color} />;
})}
</KpiGrid>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdLocalOffer size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Pricing Catalog
</Text>
<Text weight="bold">
{pricing.length} total · {rows.length} shown
</Text>
</VStack>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search pricing (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
{isMobile ? (
rows.length === 0 && !isLoading ? (
<EmptyState
@@ -363,7 +364,14 @@ const ClientsPricing = () => {
</VStack>
</HStack>
<HStack gap={0.5}>
<IconButton label="Edit rule" tooltip="Edit rule" size="sm" variant="ghost" icon={<MdEdit size={14} />} onClick={() => openEditDialog(row)} />
<IconButton
label="Edit rule"
tooltip="Edit rule"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEditDialog(row)}
/>
<IconButton
label="Delete rule"
tooltip="Delete rule"
@@ -491,7 +499,13 @@ const ClientsPricing = () => {
<TableCell>
<HStack gap={1} justify="end">
<Tooltip content="Edit rule">
<IconButton label="Edit rule" size="sm" variant="ghost" icon={<MdEdit size={16} />} onClick={() => openEditDialog(row)} />
<IconButton
label="Edit rule"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEditDialog(row)}
/>
</Tooltip>
<Tooltip content="Delete rule">
<IconButton
@@ -511,8 +525,8 @@ const ClientsPricing = () => {
</Table>
</TableScroll>
)}
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={720} purpose="form">
<Layout
@@ -529,30 +543,81 @@ const ClientsPricing = () => {
}
content={
<LayoutContent padding={4}>
<Grid columns={{ minWidth: 220 }} gap={3}>
<Selector label="Tenant" options={tenantSelectOptions} value={form.tenant} onChange={set('tenant')} placeholder="Choose tenant" hasSearch isRequired />
<Selector label="Zone" options={locationSelectOptions} value={form.location} onChange={set('location')} placeholder="Choose zone" hasSearch isOptional />
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<Selector
label="Tenant"
options={tenantSelectOptions}
value={form.tenant}
onChange={set('tenant')}
placeholder="Choose tenant"
hasSearch
isRequired
/>
<Selector
label="Zone"
options={locationSelectOptions}
value={form.location}
onChange={set('location')}
placeholder="Choose zone"
hasSearch
isOptional
/>
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={form.vehicletype} onChange={set('vehicletype')} />
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
<NumberInput label="Base Price" value={form.baseprice === '' ? undefined : Number(form.baseprice)} onChange={(v) => set('baseprice')(v ?? '')} isRequired />
<NumberInput label="Base Weight (kg)" value={form.baseweight === '' ? undefined : Number(form.baseweight)} onChange={(v) => set('baseweight')(v ?? '')} isOptional />
<NumberInput label="Price / KG" value={form.priceperkg === '' ? undefined : Number(form.priceperkg)} onChange={(v) => set('priceperkg')(v ?? '')} isOptional />
<NumberInput label="Base Distance (km)" value={form.basedistance === '' ? undefined : Number(form.basedistance)} onChange={(v) => set('basedistance')(v ?? '')} isOptional />
<NumberInput label="Price / KM" value={form.priceperkm === '' ? undefined : Number(form.priceperkm)} onChange={(v) => set('priceperkm')(v ?? '')} isRequired />
<NumberInput
label="Base Price"
value={form.baseprice === '' ? undefined : Number(form.baseprice)}
onChange={(v) => set('baseprice')(v ?? '')}
isRequired
/>
<NumberInput
label="Base Weight (kg)"
value={form.baseweight === '' ? undefined : Number(form.baseweight)}
onChange={(v) => set('baseweight')(v ?? '')}
isOptional
/>
<NumberInput
label="Price / KG"
value={form.priceperkg === '' ? undefined : Number(form.priceperkg)}
onChange={(v) => set('priceperkg')(v ?? '')}
isOptional
/>
<NumberInput
label="Base Distance (km)"
value={form.basedistance === '' ? undefined : Number(form.basedistance)}
onChange={(v) => set('basedistance')(v ?? '')}
isOptional
/>
<NumberInput
label="Price / KM"
value={form.priceperkm === '' ? undefined : Number(form.priceperkm)}
onChange={(v) => set('priceperkm')(v ?? '')}
isRequired
/>
<NumberInput
label="Handling Charges"
value={form.handlingcharges === '' ? undefined : Number(form.handlingcharges)}
onChange={(v) => set('handlingcharges')(v ?? '')}
isOptional
/>
<NumberInput label="Priority" value={form.priority === '' ? undefined : Number(form.priority)} onChange={(v) => set('priority')(v ?? '')} isOptional />
<NumberInput
label="Priority"
value={form.priority === '' ? undefined : Number(form.priority)}
onChange={(v) => set('priority')(v ?? '')}
isOptional
/>
</Grid>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={savePricingMutation.isPending} />
<Button
label="Cancel"
variant="secondary"
onClick={() => setDialogOpen(false)}
isDisabled={savePricingMutation.isPending}
/>
<Button
label={form.pricingid ? 'Save Changes' : 'Create'}
variant="primary"
@@ -598,17 +663,46 @@ const ClientsPricing = () => {
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Selector label="Tenant" options={tenantSelectOptions} value={quoteForm.tenant} onChange={setQuote('tenant')} placeholder="Choose tenant" hasSearch isRequired />
<Selector label="Zone" options={locationSelectOptions} value={quoteForm.location} onChange={setQuote('location')} placeholder="Choose zone" hasSearch isOptional />
<Grid columns={{ minWidth: 160 }} gap={3}>
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={quoteForm.vehicletype} onChange={setQuote('vehicletype')} />
<NumberInput label="Weight (kg)" value={quoteForm.weight === '' ? undefined : Number(quoteForm.weight)} onChange={(v) => setQuote('weight')(v ?? '')} isOptional />
<NumberInput label="Distance (km)" value={quoteForm.distance === '' ? undefined : Number(quoteForm.distance)} onChange={(v) => setQuote('distance')(v ?? '')} isRequired />
<Selector
label="Tenant"
options={tenantSelectOptions}
value={quoteForm.tenant}
onChange={setQuote('tenant')}
placeholder="Choose tenant"
hasSearch
isRequired
/>
<Selector
label="Zone"
options={locationSelectOptions}
value={quoteForm.location}
onChange={setQuote('location')}
placeholder="Choose zone"
hasSearch
isOptional
/>
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={3}>
<Selector
label="Vehicle Type"
options={VEHICLE_TYPE_OPTIONS}
value={quoteForm.vehicletype}
onChange={setQuote('vehicletype')}
/>
<NumberInput
label="Weight (kg)"
value={quoteForm.weight === '' ? undefined : Number(quoteForm.weight)}
onChange={(v) => setQuote('weight')(v ?? '')}
isOptional
/>
<NumberInput
label="Distance (km)"
value={quoteForm.distance === '' ? undefined : Number(quoteForm.distance)}
onChange={(v) => setQuote('distance')(v ?? '')}
isRequired
/>
</Grid>
{quoteMutation.isPending && (
<Text color="secondary">Calculating…</Text>
)}
{quoteMutation.isPending && <Text color="secondary">Calculating…</Text>}
{quoteMutation.data && (
<Card padding={3} elevation="none" style={{ backgroundColor: DT.surfaceAlt }}>

View File

@@ -56,6 +56,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getalltenants } from 'pages/api/api';
import {
@@ -209,7 +210,14 @@ const TenantLocationsDialog = ({ tenant, open, onClose }) => {
{[loc.address, loc.city, loc.state, loc.pincode].filter(Boolean).join(', ')}
</Text>
</VStack>
<IconButton label="Edit location" tooltip="Edit location" size="sm" variant="ghost" icon={<MdEdit size={14} />} onClick={() => openEdit(loc)} />
<IconButton
label="Edit location"
tooltip="Edit location"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(loc)}
/>
</HStack>
</Card>
))}
@@ -217,7 +225,11 @@ const TenantLocationsDialog = ({ tenant, open, onClose }) => {
)}
{formOpen && (
<Grid columns={{ minWidth: 200 }} gap={2} style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}>
<Grid
columns={{ minWidth: 200, repeat: 'fit' }}
gap={2}
style={{ marginTop: 'var(--spacing-6)', paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}
>
<TextInput label="Location Name" value={form.locationname} onChange={(v) => setForm((f) => ({ ...f, locationname: v }))} />
<TextInput label="Pincode" value={form.pincode} onChange={(v) => setForm((f) => ({ ...f, pincode: v }))} />
<TextInput label="Address" value={form.address} onChange={(v) => setForm((f) => ({ ...f, address: v }))} />
@@ -393,7 +405,14 @@ const TenantCustomersDialog = ({ tenant, open, onClose }) => {
</Text>
</VStack>
<HStack gap={1}>
<IconButton label="Edit customer" tooltip="Edit customer" size="sm" variant="ghost" icon={<MdEdit size={14} />} onClick={() => openEdit(c)} />
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(c)}
/>
<IconButton
label="Delete customer"
tooltip="Delete customer"
@@ -410,7 +429,11 @@ const TenantCustomersDialog = ({ tenant, open, onClose }) => {
)}
{formOpen && (
<Grid columns={{ minWidth: 200 }} gap={2} style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}>
<Grid
columns={{ minWidth: 200, repeat: 'fit' }}
gap={2}
style={{ marginTop: 'var(--spacing-6)', paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}
>
<TextInput label="First Name" value={form.firstname} onChange={(v) => setForm((f) => ({ ...f, firstname: v }))} />
<TextInput label="Last Name" value={form.lastname} onChange={(v) => setForm((f) => ({ ...f, lastname: v }))} />
<TextInput label="Phone" value={form.phone} onChange={(v) => setForm((f) => ({ ...f, phone: v }))} />
@@ -670,9 +693,7 @@ const Clients1 = () => {
if (String(t.status || '').toLowerCase() !== activeStatus) return false;
if (!q) return true;
return (
t.tenantname?.toLowerCase().includes(q) ||
t.primaryemail?.toLowerCase().includes(q) ||
t.primarycontact?.includes(debouncedSearch)
t.tenantname?.toLowerCase().includes(q) || t.primaryemail?.toLowerCase().includes(q) || t.primarycontact?.includes(debouncedSearch)
);
});
}, [tenantList, value0, debouncedSearch]);
@@ -913,12 +934,16 @@ const Clients1 = () => {
>
{value1 == '0' && (
<VStack gap={0} padding={4}>
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<InfoRow icon={FiUser} value={row.tenantname} caption="Tenant" />
<InfoRow icon={FaRegCheckCircle} value={row.status || '—'} caption="Status" />
<InfoRow icon={FiPhoneCall} value={row.primarycontact} caption="Phone" />
<InfoRow icon={LuMail} value={row.primaryemail} caption="E-Mail" />
<InfoRow icon={row.requiredeliveryotp ? MdCheckCircle : MdCancel} value={row.requiredeliveryotp ? 'Yes' : 'No'} caption="Delivery OTP Required" />
<InfoRow
icon={row.requiredeliveryotp ? MdCheckCircle : MdCancel}
value={row.requiredeliveryotp ? 'Yes' : 'No'}
caption="Delivery OTP Required"
/>
</Grid>
</VStack>
)}
@@ -947,20 +972,14 @@ const Clients1 = () => {
{value0 !== 1 && <Tab value="1" label="Edit Pricing" icon={<GiMoneyStack size={16} />} />}
</TabList>
{value2 == '1' && (
<Button
label="Add Pricing"
variant="destructive"
size="sm"
icon={<MdEdit size={14} />}
onClick={() => setDialogopen(true)}
/>
<Button label="Add Pricing" variant="destructive" size="sm" icon={<MdEdit size={14} />} onClick={() => setDialogopen(true)} />
)}
</HStack>
}
>
{value2 == '0' && (
<VStack gap={3} padding={4}>
<Grid columns={{ minWidth: 240 }} gap={3}>
<Grid columns={{ minWidth: 240, repeat: 'fit' }} gap={3}>
<TextInput
label="Tenant Name"
value={editClient.tenantname ?? selectedCustomer.tenantname ?? ''}
@@ -995,7 +1014,7 @@ const Clients1 = () => {
onPlaceSelected={handleAddressPlaceSelected}
/>
<Grid columns={{ minWidth: 200 }} gap={3}>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<TextInput
label="City"
value={city == '' ? selectedCustomer.city || '' : city}
@@ -1012,7 +1031,7 @@ const Clients1 = () => {
/>
</Grid>
<Grid columns={{ minWidth: 200 }} gap={3}>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<TextInput label="Latitude" value={latlong.lat ?? ''} isDisabled startIcon={TbWorldLatitude} />
<TextInput label="Longitude" value={latlong.lng ?? ''} isDisabled startIcon={TbWorldLongitude} />
</Grid>
@@ -1034,70 +1053,92 @@ const Clients1 = () => {
<>
{(getalltenantsIsLoading || isloader) && <Loader />}
<PageHeader
title="Tenants"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} setPage={setPage} placeholder="Select Zone" />}
/>
<PageShell>
<PageHeader
title="Tenants"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
placeholder="Select Zone"
/>
}
/>
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
{KPI_META.map((item) => {
const Icon = item.icon;
const value = tenantStatusCounts[item.countKey] ?? 0;
// 'total' has no matching entry in STATUS_TABS (there's no "All"
// tab) — only the three status tiles switch tabs when clicked.
const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
const clickable = tabIdx !== -1;
return (
<div
key={item.key}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => handleChange(tabIdx) : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx);
}
: undefined
}
style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }}
>
<StatCard title={item.label} value={value ?? 0} icon={<Icon size={20} />} color={item.color} loading={getalltenantsIsLoading} />
</div>
);
})}
</Grid>
<KpiGrid>
{KPI_META.map((item) => {
const Icon = item.icon;
const value = tenantStatusCounts[item.countKey] ?? 0;
// 'total' has no matching entry in STATUS_TABS (there's no "All"
// tab) — only the three status tiles switch tabs when clicked.
const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
const clickable = tabIdx !== -1;
return (
<div
key={item.key}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => handleChange(tabIdx) : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx);
}
: undefined
}
style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }}
>
<StatCard
title={item.label}
value={value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={getalltenantsIsLoading}
/>
</div>
);
})}
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={2} wrap="wrap-reverse">
<TabList
value={String(value0)}
onChange={(v) => {
const idx = Number(v);
setstatus(STATUS_TABS[idx].status);
handleChange(idx);
}}
size="sm"
hasDivider={false}
>
{STATUS_TABS.map((t, idx) => {
const meta = STATUS_META[t.status];
const Icon = meta.icon;
const count = tenantStatusCounts[t.countKey] ?? 0;
return <Tab key={t.status} value={String(idx)} label={meta.label} icon={<Icon size={15} />} endContent={<Badge variant="neutral" label={count} />} />;
})}
</TabList>
<DataCard
tabs={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<TabList
value={String(value0)}
onChange={(v) => {
const idx = Number(v);
setstatus(STATUS_TABS[idx].status);
handleChange(idx);
}}
size="sm"
hasDivider={false}
>
{STATUS_TABS.map((t, idx) => {
const meta = STATUS_META[t.status];
const Icon = meta.icon;
const count = tenantStatusCounts[t.countKey] ?? 0;
return (
<Tab
key={t.status}
value={String(idx)}
label={meta.label}
icon={<Icon size={15} />}
endContent={<Badge variant="neutral" label={count} />}
/>
);
})}
</TabList>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<VStack width={280} padding={0} gap={0}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</VStack>
</HStack>
}
>
{!isMobile && (
<TableScroll minWidth={960}>
<Table density="balanced" dividers="rows" hasHover>
@@ -1272,15 +1313,20 @@ const Clients1 = () => {
</HStack>
</>
)}
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogopen} onOpenChange={(next) => !next && dialogclose()} width={640} purpose="form">
<Layout
header={<DialogHeader title={`Client Pricing - (${selectedCustomer.tenantname || ''})`} onOpenChange={(next) => !next && dialogclose()} />}
header={
<DialogHeader
title={`Client Pricing - (${selectedCustomer.tenantname || ''})`}
onOpenChange={(next) => !next && dialogclose()}
/>
}
content={
<LayoutContent padding={4}>
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<Selector
label="Select Slab"
options={VEHICLE_TYPE_OPTIONS}

View File

@@ -16,6 +16,7 @@ import { Divider } from '@astryxdesign/core/Divider';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import { createTenantCustomer, getAdminTenants } from 'pages/api/doormileApi';
@@ -161,86 +162,104 @@ const Createcustomer = () => {
<>
{createCustomerMutation.isPending && <Loader />}
<PageHeader
title="Create Customer"
subtitle="Add a customer record"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
<PageShell>
<PageHeader
title="Create Customer"
subtitle="Add a customer record"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<VStack gap={1.25} padding={0}>
<Text type="label" weight="semibold" color="secondary">
Location
</Text>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</VStack>
{isStaffLogin && (
<Selector
label="Choose Client"
options={tenantOptions}
value={tid ? String(tid) : ''}
onChange={(v) => setTid(v || '')}
placeholder="Choose Client"
hasSearch
<Card padding={4} elevation="none">
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260, repeat: 'fit' }} gap={3}>
<VStack gap={1.25} padding={0}>
<Text type="label" weight="semibold" color="secondary">
Location
</Text>
<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} />
</VStack>
{isStaffLogin && (
<Selector
label="Choose Client"
options={tenantOptions}
value={tid ? String(tid) : ''}
onChange={(v) => setTid(v || '')}
placeholder="Choose Client"
hasSearch
/>
)}
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={phone}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPhone(digits);
}}
isRequired
/>
)}
<TextInput label="Name" placeholder="First Name" value={firstname} onChange={setFirstname} isRequired />
<TextInput label="Last Name" placeholder="Last Name" value={lastname} onChange={setLastname} isOptional />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={phone}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPhone(digits);
}}
isRequired
<TextInput label="Email" type="email" placeholder="Email Address" value={email} onChange={setEmail} isOptional />
<TextInput label="Door No" placeholder="Door No" value={doorno} onChange={setDoorno} isOptional />
</Grid>
<Divider />
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved
on submit.
</Text>
<AddressAutocomplete
id="customer-address"
label="Address"
value={inputValue2}
onChange={setInputValue2}
onPlaceSelected={handlePlaceSelected}
bias={{ lat: appLocaLat, lng: appLocaLng }}
/>
<TextInput label="Email" type="email" placeholder="Email Address" value={email} onChange={setEmail} isOptional />
<TextInput label="Door No" placeholder="Door No" value={doorno} onChange={setDoorno} isOptional />
</Grid>
<Divider />
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput
label="Location"
placeholder="Location"
value={pickCust.suburb || ''}
onChange={(v) => setPickCust((p) => ({ ...p, suburb: v }))}
/>
<TextInput
label="City"
placeholder="City"
value={pickCust.city || ''}
onChange={(v) => setPickCust((p) => ({ ...p, city: v }))}
/>
<TextInput
label="State"
placeholder="State"
value={pickCust.state || ''}
onChange={(v) => setPickCust((p) => ({ ...p, state: v }))}
/>
<TextInput
label="Post Code"
placeholder="Postcode"
value={pickCust.postcode || ''}
onChange={(v) => setPickCust((p) => ({ ...p, postcode: v.replace(/\D/g, '') }))}
/>
<TextInput label="Landmark" placeholder="Landmark" value={landmark} onChange={setLandmark} />
<TextInput label="Latitude" value={startPoint.latitude || ''} isDisabled />
<TextInput label="Longitude" value={startPoint.longitude || ''} isDisabled />
</Grid>
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved on submit.
</Text>
<AddressAutocomplete
id="customer-address"
label="Address"
value={inputValue2}
onChange={setInputValue2}
onPlaceSelected={handlePlaceSelected}
bias={{ lat: appLocaLat, lng: appLocaLng }}
/>
<Grid columns={{ minWidth: 220 }} gap={3}>
<TextInput label="Location" placeholder="Location" value={pickCust.suburb || ''} onChange={(v) => setPickCust((p) => ({ ...p, suburb: v }))} />
<TextInput label="City" placeholder="City" value={pickCust.city || ''} onChange={(v) => setPickCust((p) => ({ ...p, city: v }))} />
<TextInput label="State" placeholder="State" value={pickCust.state || ''} onChange={(v) => setPickCust((p) => ({ ...p, state: v }))} />
<TextInput
label="Post Code"
placeholder="Postcode"
value={pickCust.postcode || ''}
onChange={(v) => setPickCust((p) => ({ ...p, postcode: v.replace(/\D/g, '') }))}
/>
<TextInput label="Landmark" placeholder="Landmark" value={landmark} onChange={setLandmark} />
<TextInput label="Latitude" value={startPoint.latitude || ''} isDisabled />
<TextInput label="Longitude" value={startPoint.longitude || ''} isDisabled />
</Grid>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createCustomerMutation.isPending} />
</HStack>
</VStack>
</Card>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createCustomerMutation.isPending} />
</HStack>
</VStack>
</Card>
</PageShell>
</>
);
};

View File

@@ -15,6 +15,7 @@ import { Switch } from '@astryxdesign/core/Switch';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { createAdminTenant } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { AccentAvatar } from 'themes/dt/primitives';
@@ -85,45 +86,60 @@ const Createclient = () => {
<>
{createTenantMutation.isPending && <Loader />}
<PageHeader
title="Create Client"
subtitle="Add a new tenant"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
<PageShell>
<PageHeader
title="Create Client"
subtitle="Add a new tenant"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdPersonAddAlt1 size={20} />
</AccentAvatar>
}
/>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput label="Client / Business Name" placeholder="e.g. DailyGrubs" value={tenantname} onChange={setTenantname} isRequired />
<Selector label="Status" options={STATUS_OPTIONS} value={status} onChange={setStatus} />
<TextInput
label="Primary Contact Number"
placeholder="Contact Number"
value={primarycontact}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPrimarycontact(digits);
}}
isRequired
<Card padding={4} elevation="none">
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260, repeat: 'fit' }} gap={3}>
<TextInput
label="Client / Business Name"
placeholder="e.g. DailyGrubs"
value={tenantname}
onChange={setTenantname}
isRequired
/>
<Selector label="Status" options={STATUS_OPTIONS} value={status} onChange={setStatus} />
<TextInput
label="Primary Contact Number"
placeholder="Contact Number"
value={primarycontact}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setPrimarycontact(digits);
}}
isRequired
/>
<TextInput
label="Primary Email"
type="email"
placeholder="Primary Email Address"
value={primaryemail}
onChange={setPrimaryemail}
isRequired
/>
</Grid>
<Switch
label="Require delivery OTP for this client (off by default)"
value={requiredeliveryotp}
onChange={setRequiredeliveryotp}
/>
<TextInput label="Primary Email" type="email" placeholder="Primary Email Address" value={primaryemail} onChange={setPrimaryemail} isRequired />
</Grid>
<Switch
label="Require delivery OTP for this client (off by default)"
value={requiredeliveryotp}
onChange={setRequiredeliveryotp}
/>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createTenantMutation.isPending} />
</HStack>
</VStack>
</Card>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createTenantMutation.isPending} />
</HStack>
</VStack>
</Card>
</PageShell>
</>
);
};

View File

@@ -5,7 +5,6 @@ import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -13,6 +12,7 @@ import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput';
import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Divider } from '@astryxdesign/core/Divider';
import { Pagination } from '@astryxdesign/core/Pagination';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
@@ -20,6 +20,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import {
getCompetitorBranches,
createCompetitorBranch,
@@ -227,119 +228,148 @@ const CompetitiveIntel = () => {
<>
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
<PageHeader
title="Competitive Intel"
subtitle="Live · Market Benchmarking"
live
action={
<Button
label={tab === 'branches' ? 'New Branch' : 'New Carrier Rate'}
variant="primary"
icon={<MdAdd size={16} />}
onClick={openCreate}
/>
}
/>
<PageShell>
<PageHeader
title="Competitive Intel"
subtitle="Live · Market Benchmarking"
live
action={
<Button
label={tab === 'branches' ? 'New Branch' : 'New Carrier Rate'}
variant="primary"
icon={<MdAdd size={16} />}
onClick={openCreate}
/>
}
/>
<TabList value={tab} onChange={changeTab} hasDivider>
{TABS.map((t) => (
<Tab key={t.key} value={t.key} label={t.label} />
))}
</TabList>
<Text color="secondary" weight="bold" style={{ marginTop: 12, marginBottom: 8 }}>
{total} total · page {page} of {pageCount}
</Text>
<Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
<TableScroll minWidth={tab === 'branches' ? 920 : 780}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
{tab === 'branches' ? (
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Company</TableHeaderCell>
<TableHeaderCell>Area</TableHeaderCell>
<TableHeaderCell>Phone</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell>Rate/Kg</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
) : (
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Company</TableHeaderCell>
<TableHeaderCell>Weight Slab</TableHeaderCell>
<TableHeaderCell>Zone</TableHeaderCell>
<TableHeaderCell>Rate</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
)}
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={7} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7}>
<EmptyState title="No records to show" description="Add a record to start benchmarking against it." icon={<MdRadar size={28} />} />
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={row.id || index}>
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell>
{tab === 'branches' ? (
<>
<TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.area || '—'}</TableCell>
<TableCell>{row.phone || '—'}</TableCell>
<TableCell style={{ maxWidth: 220 }}>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}>
{row.address || '—'}
</Text>
</TableCell>
<TableCell>{row.rate_per_kg || '—'}</TableCell>
</>
) : (
<>
<TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.weight_slab || '—'}</TableCell>
<TableCell>{row.zone || '—'}</TableCell>
<TableCell>{row.rate || '—'}</TableCell>
</>
)}
<TableCell>
<HStack gap={1} justify="end">
<IconButton label="Edit" tooltip="Edit" size="sm" variant="ghost" icon={<MdEdit size={16} />} onClick={() => openEdit(row)} />
<IconButton
label="Delete"
tooltip="Delete"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
<DataCard
toolbar={
<FilterToolbar max={1}>
<Text color="secondary" weight="bold">
{total} total · page {page} of {pageCount}
</Text>
</FilterToolbar>
}
tabs={
<TabList value={tab} onChange={changeTab}>
{TABS.map((t) => (
<Tab key={t.key} value={t.key} label={t.label} />
))}
</TabList>
}
>
<TableScroll minWidth={tab === 'branches' ? 920 : 780}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
{tab === 'branches' ? (
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Company</TableHeaderCell>
<TableHeaderCell>Area</TableHeaderCell>
<TableHeaderCell>Phone</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell>Rate/Kg</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
) : (
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Company</TableHeaderCell>
<TableHeaderCell>Weight Slab</TableHeaderCell>
<TableHeaderCell>Zone</TableHeaderCell>
<TableHeaderCell>Rate</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
)}
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={7} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7}>
<EmptyState
title="No records to show"
description="Add a record to start benchmarking against it."
icon={<MdRadar size={28} />}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</Card>
) : (
rows.map((row, index) => (
<TableRow key={row.id || index}>
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell>
{tab === 'branches' ? (
<>
<TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.area || '—'}</TableCell>
<TableCell>{row.phone || '—'}</TableCell>
<TableCell style={{ maxWidth: 220 }}>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}>
{row.address || '—'}
</Text>
</TableCell>
<TableCell>{row.rate_per_kg || '—'}</TableCell>
</>
) : (
<>
<TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.weight_slab || '—'}</TableCell>
<TableCell>{row.zone || '—'}</TableCell>
<TableCell>{row.rate || '—'}</TableCell>
</>
)}
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Edit"
tooltip="Edit"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete"
tooltip="Delete"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
{pageCount > 1 && (
<HStack justify="center" style={{ marginTop: 16 }}>
<Pagination page={page} onChange={setPage} totalPages={pageCount} />
</HStack>
)}
{pageCount > 1 && (
<>
<Divider />
<HStack justify="center" paddingBlock={2}>
<Pagination page={page} onChange={setPage} totalPages={pageCount} />
</HStack>
</>
)}
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={720} purpose="form">
<Layout
header={
<DialogHeader
title={tab === 'branches' ? (branchForm.id ? 'Edit Branch' : 'New Competitor Branch') : carrierForm.id ? 'Edit Carrier Rate' : 'New Carrier Rate'}
title={
tab === 'branches'
? branchForm.id
? 'Edit Branch'
: 'New Competitor Branch'
: carrierForm.id
? 'Edit Carrier Rate'
: 'New Carrier Rate'
}
onOpenChange={(next) => !next && setDialogOpen(false)}
startContent={
<AccentAvatar color={BRAND} selected size={36}>
@@ -351,7 +381,7 @@ const CompetitiveIntel = () => {
content={
<LayoutContent padding={4}>
{tab === 'branches' ? (
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput label="Company" value={branchForm.company} onChange={setBranch('company')} />
<TextInput label="Area" value={branchForm.area} onChange={setBranch('area')} />
<TextInput label="Phone" value={branchForm.phone} onChange={setBranch('phone')} />
@@ -364,14 +394,29 @@ const CompetitiveIntel = () => {
placeholder="e.g. 600, or a note like 'Not Answered'"
/>
<TextInput label="Time (days)" value={branchForm.time_in_days} onChange={setBranch('time_in_days')} />
<TextInput label="Offers Pickup" value={branchForm.offers_pickup} onChange={setBranch('offers_pickup')} placeholder="yes / no" />
<TextInput label="Offers Drop" value={branchForm.offers_drop} onChange={setBranch('offers_drop')} placeholder="yes / no" />
<TextInput
label="Offers Pickup"
value={branchForm.offers_pickup}
onChange={setBranch('offers_pickup')}
placeholder="yes / no"
/>
<TextInput
label="Offers Drop"
value={branchForm.offers_drop}
onChange={setBranch('offers_drop')}
placeholder="yes / no"
/>
<TextInput label="Packing Charge" value={branchForm.packing_charge} onChange={setBranch('packing_charge')} />
</Grid>
) : (
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput label="Company" value={carrierForm.company} onChange={setCarrier('company')} />
<TextInput label="Weight Slab" value={carrierForm.weight_slab} onChange={setCarrier('weight_slab')} placeholder="e.g. 500g - 1kg" />
<TextInput
label="Weight Slab"
value={carrierForm.weight_slab}
onChange={setCarrier('weight_slab')}
placeholder="e.g. 500g - 1kg"
/>
<TextInput label="Service Type" value={carrierForm.service_type} onChange={setCarrier('service_type')} />
<TextInput label="Zone" value={carrierForm.zone} onChange={setCarrier('zone')} placeholder="e.g. Local / Within City" />
<TextInput label="Rate" value={carrierForm.rate} onChange={setCarrier('rate')} placeholder="e.g. 120-200" />

View File

@@ -17,7 +17,6 @@ import {
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -34,6 +33,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
@@ -162,55 +162,66 @@ const Customers = () => {
<>
{isLoading && <Loader />}
<PageHeader
title="Customers"
subtitle="Live · B2C Directory"
live
action={
<Tooltip content="Adds a contact to your tenant's own customer book (used when creating orders) — separate from the order-derived B2C directory below">
<Button label="Add Customer" variant="primary" icon={<MdPersonAddAlt1 size={16} />} onClick={() => navigate('/doormile/customer/create')} />
</Tooltip>
}
/>
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} />
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
<StatCard title="With Phone" value={stats.withPhone} icon={<MdOutlinePhoneAndroid size={20} />} color="#8b5cf6" />
<StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" />
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Directory
</Text>
<Text weight="bold">
{customers.length} total · {rows.length} shown
</Text>
</VStack>
</HStack>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search customers (ctrl+k)"
<PageShell>
<PageHeader
title="Customers"
subtitle="Live · B2C Directory"
live
action={
<Tooltip content="Adds a contact to your tenant's own customer book (used when creating orders) — separate from the order-derived B2C directory below">
<Button
label="Add Customer"
variant="primary"
icon={<MdPersonAddAlt1 size={16} />}
onClick={() => navigate('/doormile/customer/create')}
/>
</div>
</HStack>
</Card>
</Tooltip>
}
/>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<KpiGrid>
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} />
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
<StatCard title="With Phone" value={stats.withPhone} icon={<MdOutlinePhoneAndroid size={20} />} color="#8b5cf6" />
<StatCard title="Total Bookings" value={stats.totalBookings} icon={<MdOutlineReceiptLong size={20} />} color="#10b981" />
</KpiGrid>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Directory
</Text>
<Text weight="bold">
{customers.length} total · {rows.length} shown
</Text>
</VStack>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search customers (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
{isMobile ? (
rows.length === 0 && !isLoading ? (
<EmptyState title="No customers to show" description="Customers appear here once they've placed an order." icon={<MdPeopleAlt size={28} />} />
<EmptyState
title="No customers to show"
description="Customers appear here once they've placed an order."
icon={<MdPeopleAlt size={28} />}
/>
) : (
<MobileCardList scroll>
{rows.map((row, index) => (
@@ -232,7 +243,14 @@ const Customers = () => {
</Text>
</VStack>
</HStack>
<IconButton label="Edit customer" tooltip="Edit customer" size="sm" variant="ghost" icon={<MdEdit size={14} />} onClick={() => openEdit(row)} />
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(row)}
/>
</HStack>
}
>
@@ -262,7 +280,11 @@ const Customers = () => {
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={5}>
<EmptyState title="No customers to show" description="Customers appear here once they've placed an order." icon={<MdPeopleAlt size={28} />} />
<EmptyState
title="No customers to show"
description="Customers appear here once they've placed an order."
icon={<MdPeopleAlt size={28} />}
/>
</TableCell>
</TableRow>
) : (
@@ -305,7 +327,14 @@ const Customers = () => {
<TableCell>{custBookings(row)}</TableCell>
<TableCell>
<HStack justify="end">
<IconButton label="Edit customer" tooltip="Edit customer" size="sm" variant="ghost" icon={<MdEdit size={16} />} onClick={() => openEdit(row)} />
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
</HStack>
</TableCell>
</TableRow>
@@ -315,8 +344,8 @@ const Customers = () => {
</Table>
</TableScroll>
)}
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={!!editRow} onOpenChange={(next) => !next && setEditRow(null)} width={760} purpose="form">
<Layout
@@ -335,7 +364,7 @@ const Customers = () => {
content={
<LayoutContent padding={4}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput label="Customer Name" value={form.name || ''} onChange={(v) => setForm((f) => ({ ...f, name: v }))} />
<TextInput
label="Contact Number"
@@ -343,23 +372,48 @@ const Customers = () => {
onChange={(v) => setForm((f) => ({ ...f, phone: v.replace(/\D/g, '').slice(0, 10) }))}
startIcon={MdOutlinePhoneAndroid}
/>
<TextInput label="Email" type="email" value={form.email || ''} onChange={(v) => setForm((f) => ({ ...f, email: v }))} startIcon={MdMail} />
<TextInput
label="Email"
type="email"
value={form.email || ''}
onChange={(v) => setForm((f) => ({ ...f, email: v }))}
startIcon={MdMail}
/>
</Grid>
<Divider />
<Text type="supporting" color="secondary" style={{ fontStyle: 'italic' }}>
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be saved on submit.
The backend doesn&apos;t store an address against a customer yet — these fields aren&apos;t required and won&apos;t be
saved on submit.
</Text>
<AddressAutocomplete id="edit-customer-address" label="Address" value={addressInput} onChange={setAddressInput} onPlaceSelected={handleAddressPlaceSelected} />
<AddressAutocomplete
id="edit-customer-address"
label="Address"
value={addressInput}
onChange={setAddressInput}
onPlaceSelected={handleAddressPlaceSelected}
/>
<Grid columns={{ minWidth: 200 }} gap={3}>
<TextInput label="Location" value={pickAddress.suburb || ''} onChange={(v) => setPickAddress((p) => ({ ...p, suburb: v }))} />
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<TextInput
label="Location"
value={pickAddress.suburb || ''}
onChange={(v) => setPickAddress((p) => ({ ...p, suburb: v }))}
/>
<TextInput label="City" value={pickAddress.city || ''} onChange={(v) => setPickAddress((p) => ({ ...p, city: v }))} />
<TextInput label="State" value={pickAddress.state || ''} onChange={(v) => setPickAddress((p) => ({ ...p, state: v }))} />
<TextInput label="Postcode" value={pickAddress.postcode || ''} onChange={(v) => setPickAddress((p) => ({ ...p, postcode: v }))} />
<TextInput label="Landmark" value={pickAddress.landmark || ''} onChange={(v) => setPickAddress((p) => ({ ...p, landmark: v }))} />
<TextInput
label="Postcode"
value={pickAddress.postcode || ''}
onChange={(v) => setPickAddress((p) => ({ ...p, postcode: v }))}
/>
<TextInput
label="Landmark"
value={pickAddress.landmark || ''}
onChange={(v) => setPickAddress((p) => ({ ...p, landmark: v }))}
/>
<TextInput label="Latitude" value={latLng.latitude || ''} isDisabled />
<TextInput label="Longitude" value={latLng.longitude || ''} isDisabled />
</Grid>

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@
flex-direction: column;
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, sans-serif;
font-family: var(--font-family-body);
overflow: hidden;
position: relative;
}
@@ -260,7 +260,7 @@
font-size: 13px;
color: var(--text);
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
background: var(--bg-sub);
padding: 7px 16px;
border-radius: 10px;
@@ -4367,7 +4367,7 @@
}
.dispatch-container .cmp-pop {
font-family: 'Inter', -apple-system, sans-serif;
font-family: var(--font-family-body);
color: #0f172a;
line-height: 1.35;
}
@@ -4479,7 +4479,7 @@
}
.dispatch-container .cmp-pop-coord {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-family-code);
font-size: 10.5px;
font-weight: 600;
color: #475569;
@@ -4798,7 +4798,7 @@
/* Custom Order ID & Phone tags */
.dispatch-container .pu-order-tag {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-size: 11px;
color: #4f46e5 !important;
background: #f5f3ff;
@@ -4808,13 +4808,13 @@
}
.dispatch-container .pu-phone-tag {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-size: 11.5px;
color: #334155;
}
.dispatch-container .pu-coord-tag {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-size: 10.5px;
color: #64748b !important;
}
@@ -5003,7 +5003,7 @@
}
.dispatch-container .live-rider-popup .pu-order-badge {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-size: 11px;
color: #4f46e5;
background: #f5f3ff;
@@ -5017,7 +5017,7 @@
color: #0284c7;
text-decoration: none;
transition: color 0.15s ease;
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-weight: 700;
}
@@ -5039,7 +5039,7 @@
}
.dispatch-container .live-rider-popup .pu-coordinates {
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-family-code);
font-size: 11px;
color: #64748b;
background: #f8fafc;
@@ -8099,7 +8099,7 @@
}
.dispatch-container .cdp-clock-time {
font-family: 'SF Mono', 'Roboto Mono', 'Menlo', 'Consolas', monospace;
font-family: var(--font-family-code);
font-size: 24px;
font-weight: 700;
color: #f1f5f9;
@@ -9088,7 +9088,7 @@
padding: 1px 6px;
border-radius: 4px;
margin: 0 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-family-code);
font-size: 11.5px;
}
@@ -9404,7 +9404,7 @@
.dispatch-container .da-error-meta {
font-size: 11px;
color: #b45353;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-family-code);
}
/* ---- Single-batch detail view ---- */
@@ -9572,7 +9572,7 @@
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-family-code);
font-size: 11.5px;
color: #1e293b;
}
@@ -9986,7 +9986,7 @@
.dispatch-container .da-transfer-id {
font-weight: 800;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-family-code);
}
.dispatch-container .da-transfer-from {

View File

@@ -885,7 +885,7 @@ const Preview = () => {
</HStack>
</HStack>
<HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 12 }}>
<HStack gap={1.5} wrap="wrap" vAlign="center" style={{ marginTop: 'var(--spacing-6)' }}>
{r.orders.map((o, idx) => (
<StepChip
key={`${o.orderid}-${idx}`}

View File

@@ -15,6 +15,7 @@ import {
} from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { KpiGrid } from 'components/nearle_components/PageLayout';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
@@ -237,13 +238,7 @@ function CustomerCell({ order }) {
/** Orders table inside an expanded rider card. */
function OrdersBreakdownTable({ orders, getRevenue }) {
if (!orders?.length) {
return (
<EmptyState
title="No orders yet"
description="No orders assigned to this rider yet."
icon={<MdReceipt size={24} />}
/>
);
return <EmptyState title="No orders yet" description="No orders assigned to this rider yet." icon={<MdReceipt size={24} />} />;
}
return (
@@ -295,7 +290,11 @@ function CostTile({ icon, color, label, amount, footnote }) {
return (
<Card padding={3}>
<HStack gap={2} vAlign="center">
<Center width={34} height={34} style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}>
<Center
width={34}
height={34}
style={{ flexShrink: 0, borderRadius: 'var(--radius-element)', backgroundColor: `${color}14`, color }}
>
{icon}
</Center>
<VStack gap={0} padding={0}>
@@ -322,7 +321,7 @@ function RiderDetailPanel({ metrics }) {
return (
<VStack gap={3} padding={3}>
<Grid columns={{ minWidth: 200 }} gap={3}>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<CostTile
icon={<MdPayments size={16} />}
color={STATUS.info}
@@ -506,7 +505,7 @@ export default function ProfitabilitySection({
</VStack>
</HStack>
<Grid columns={{ minWidth: 200 }} gap={3}>
<KpiGrid minWidth={200}>
<StatCard
title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
value={rupees(Math.abs(totalDailyProfit))}
@@ -549,16 +548,12 @@ export default function ProfitabilitySection({
color={moneyColor(slotIsProfit)}
caption={`${slotMargin >= 0 ? '+' : ''}${slotMargin.toFixed(0)}% margin`}
/>
</Grid>
</KpiGrid>
<VStack gap={2} padding={0}>
<Text weight="bold">Riders list</Text>
{sorted.length === 0 ? (
<EmptyState
title="No riders to show"
description="No riders have orders in this slot."
icon={<MdPeopleAlt size={28} />}
/>
<EmptyState title="No riders to show" description="No riders have orders in this slot." icon={<MdPeopleAlt size={28} />} />
) : (
<VStack gap={2} padding={0}>
{sorted.map((rider) => (

View File

@@ -6,7 +6,6 @@ import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -24,6 +23,7 @@ import TableLoader from 'components/nearle_components/TableLoader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
@@ -127,38 +127,42 @@ const Exceptions = () => {
<>
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
<PageHeader
title="Exceptions"
subtitle="Live · Lost / Damaged / Misrouted Parcels"
live
action={<Button label="Log Exception" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<PageShell>
<PageHeader
title="Exceptions"
subtitle="Live · Lost / Damaged / Misrouted Parcels"
live
action={<Button label="Log Exception" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<Grid columns={{ minWidth: 180, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
<StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" />
</Grid>
<KpiGrid>
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
<StatCard title="Resolved" value={stats.resolved} icon={<MdCheckCircleOutline size={20} />} color="#10b981" />
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{exceptions.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search exceptions (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack vAlign="center">
<Text weight="bold">
{exceptions.length} total · {rows.length} shown
</Text>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search exceptions (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
<TableScroll minWidth={1020}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
@@ -221,8 +225,8 @@ const Exceptions = () => {
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<Layout
@@ -241,10 +245,18 @@ const Exceptions = () => {
content={
<LayoutContent padding={4}>
<VStack gap={3} padding={0}>
<Grid columns={{ minWidth: 220 }} gap={3}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<TextInput label="Consignment ID" value={form.consignmentid} onChange={set('consignmentid')} isRequired />
<TextInput label="Tripsheet ID" value={form.tripsheetid} onChange={set('tripsheetid')} isOptional />
<Selector label="Hub" options={hubOptions} value={form.hub} onChange={set('hub')} placeholder="Choose hub" hasSearch isOptional />
<Selector
label="Hub"
options={hubOptions}
value={form.hub}
onChange={set('hub')}
placeholder="Choose hub"
hasSearch
isOptional
/>
<Selector label="Type" options={EXCEPTION_TYPE_OPTIONS} value={form.exceptiontype} onChange={set('exceptiontype')} />
<Selector label="Severity" options={SEVERITY_OPTIONS} value={form.severity} onChange={set('severity')} />
</Grid>

View File

@@ -6,7 +6,6 @@ import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -25,6 +24,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import StatusBadge from 'components/nearle_components/StatusBadge';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
@@ -160,38 +160,42 @@ const Hubs = () => {
<>
{(isLoading || deleteMutation.isPending) && <Loader />}
<PageHeader
title="Hubs"
subtitle="Live · Sorting Centers & Delivery Hubs"
live
action={<Button label="New Hub" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<PageShell>
<PageHeader
title="Hubs"
subtitle="Live · Sorting Centers & Delivery Hubs"
live
action={<Button label="New Hub" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} />
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} />
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
</Grid>
<KpiGrid>
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} />
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} />
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{hubs.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search hubs (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack vAlign="center">
<Text weight="bold">
{hubs.length} total · {rows.length} shown
</Text>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search hubs (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
<TableScroll minWidth={960}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
@@ -256,8 +260,8 @@ const Hubs = () => {
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={680} purpose="form">
<Layout
@@ -281,7 +285,7 @@ const Hubs = () => {
Hub Details
</Text>
<TextInput label="Hub Name" value={form.hubname} onChange={set('hubname')} isRequired />
<Grid columns={{ minWidth: 180 }} gap={3}>
<Grid columns={{ minWidth: 180, repeat: 'fit' }} gap={3}>
<Selector label="Type" options={HUB_TYPE_OPTIONS} value={form.hubtype} onChange={set('hubtype')} />
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
<TextInput label="Contact No" value={form.contactno} onChange={set('contactno')} isOptional />
@@ -302,7 +306,7 @@ const Hubs = () => {
description="Read the correct id from GET /admin/hubs rather than guessing"
isRequired
/>
<Grid columns={{ minWidth: 160 }} gap={3}>
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={3}>
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional />
<NumberInput
label="Latitude"

View File

@@ -9,7 +9,6 @@ import { HiOutlineArrowLeft } from 'react-icons/hi';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -22,6 +21,7 @@ import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } f
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import HoverSocialCard from 'components/cards/statistics/HoverSocialCard';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } from '../../api/api';
@@ -210,7 +210,10 @@ const OrdersPreview = () => {
enabled: appId !== 0 // Ensures query runs only when appId is valid
});
const paymentOptions = useMemo(() => paymentModes.map((p) => ({ value: String(p.apptypeid), label: p.label || p.name || `Type #${p.apptypeid}` })), [paymentModes]);
const paymentOptions = useMemo(
() => paymentModes.map((p) => ({ value: String(p.apptypeid), label: p.label || p.name || `Type #${p.apptypeid}` })),
[paymentModes]
);
const riderOptions = useMemo(() => ridersList.map((r) => ({ value: String(r.userid), label: r.label })), [ridersList]);
// ======================================================= || notifyRiderMutation || =======================================================
@@ -368,104 +371,124 @@ const OrdersPreview = () => {
<>
{(paymentModesLoading || ridersListLoading || isLoading) && <Loader />}
<PageHeader
title="Assign Orders"
subtitle="Review before committing"
action={
<>
<IconButton label="Back to orders" tooltip="Back to orders" variant="secondary" icon={<HiOutlineArrowLeft size={20} />} onClick={() => navigate('/doormile/orders')} />
<Button label="Re-Assign" variant="secondary" icon={<MdArrowBack size={16} />} />
</>
}
/>
<PageShell>
<PageHeader
title="Assign Orders"
subtitle="Review before committing"
action={
<>
<IconButton
label="Back to orders"
tooltip="Back to orders"
variant="secondary"
icon={<HiOutlineArrowLeft size={20} />}
onClick={() => navigate('/doormile/orders')}
/>
<Button label="Re-Assign" variant="secondary" icon={<MdArrowBack size={16} />} />
</>
}
/>
{aiMode == 1 && (
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3} style={{ marginBottom: 16 }}>
<HoverSocialCard secondary={metaData?.total_orders} primary="Orders" percentage={<MdDirectionsBike />} color={STATUS.delivered} />
<HoverSocialCard secondary={metaData?.total_riders} primary="Riders" percentage={<MdDirectionsBike />} color={STATUS.pending} />
<HoverSocialCard secondary={zoneData?.length} primary="Zones" percentage={<FaMapLocationDot />} color="#0ea5e9" />
<HoverSocialCard secondary={zoneData?.length} primary="Kilometer" percentage={<FaMapLocationDot />} color={STATUS.cancelled} />
</Grid>
)}
{aiMode == 1 && (
<KpiGrid>
<HoverSocialCard
secondary={metaData?.total_orders}
primary="Orders"
percentage={<MdDirectionsBike />}
color={STATUS.delivered}
/>
<HoverSocialCard secondary={metaData?.total_riders} primary="Riders" percentage={<MdDirectionsBike />} color={STATUS.pending} />
<HoverSocialCard secondary={zoneData?.length} primary="Zones" percentage={<FaMapLocationDot />} color="#0ea5e9" />
<HoverSocialCard secondary={zoneData?.length} primary="Kilometer" percentage={<FaMapLocationDot />} color={STATUS.cancelled} />
</KpiGrid>
)}
<Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
{isMobile ? (
<MobileOrdersList list={finaldeliveryList} aiMode={aiMode} />
) : (
<TableScroll minWidth={aiMode == 1 ? 1400 : 1200}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
{aiMode == 1 && <TableHeaderCell>Zone</TableHeaderCell>}
<TableHeaderCell>Tenant</TableHeaderCell>
<TableHeaderCell>Order Location</TableHeaderCell>
<TableHeaderCell>Pickup</TableHeaderCell>
<TableHeaderCell>Delivery</TableHeaderCell>
<TableHeaderCell>Notes</TableHeaderCell>
{aiMode == 1 && <TableHeaderCell>Rider</TableHeaderCell>}
<TableHeaderCell style={{ textAlign: 'center' }}>Type</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Profit</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>KMS</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{finaldeliveryList?.length == 0 && (
<DataCard>
{isMobile ? (
<MobileOrdersList list={finaldeliveryList} aiMode={aiMode} />
) : (
<TableScroll minWidth={aiMode == 1 ? 1400 : 1200}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableCell colSpan={10}>
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
</TableCell>
<TableHeaderCell>#</TableHeaderCell>
{aiMode == 1 && <TableHeaderCell>Zone</TableHeaderCell>}
<TableHeaderCell>Tenant</TableHeaderCell>
<TableHeaderCell>Order Location</TableHeaderCell>
<TableHeaderCell>Pickup</TableHeaderCell>
<TableHeaderCell>Delivery</TableHeaderCell>
<TableHeaderCell>Notes</TableHeaderCell>
{aiMode == 1 && <TableHeaderCell>Rider</TableHeaderCell>}
<TableHeaderCell style={{ textAlign: 'center' }}>Type</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Profit</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>KMS</TableHeaderCell>
</TableRow>
)}
{finaldeliveryList?.map((val, index) => renderRow(val, index))}
</TableBody>
</Table>
</TableScroll>
)}
</TableHeader>
<TableBody>
{finaldeliveryList?.length == 0 && (
<TableRow>
<TableCell colSpan={10}>
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
</TableCell>
</TableRow>
)}
{finaldeliveryList?.map((val, index) => renderRow(val, index))}
</TableBody>
</Table>
</TableScroll>
)}
<Divider />
{aiMode == 0 && (
<Grid columns={{ minWidth: 240 }} gap={3} style={{ padding: 16 }}>
<Selector
label="Choose Payment"
options={paymentOptions}
value={payment ? String(payment.apptypeid) : ''}
onChange={(v) => {
if (!v) {
setPayment(null);
return;
}
const newValue = paymentModes.find((p) => String(p.apptypeid) === v);
setPayment(newValue);
setFinalDeliveryList((list) => list.map((item) => ({ ...item, paymenttype: newValue.apptypeid })));
}}
hasClear
hasSearch
<Divider />
{aiMode == 0 && (
<Grid columns={{ minWidth: 240, repeat: 'fit' }} gap={3} style={{ padding: 16 }}>
<Selector
label="Choose Payment"
options={paymentOptions}
value={payment ? String(payment.apptypeid) : ''}
onChange={(v) => {
if (!v) {
setPayment(null);
return;
}
const newValue = paymentModes.find((p) => String(p.apptypeid) === v);
setPayment(newValue);
setFinalDeliveryList((list) => list.map((item) => ({ ...item, paymenttype: newValue.apptypeid })));
}}
hasClear
hasSearch
/>
<Selector
label="Choose Rider"
options={riderOptions}
value={rider ? String(rider.userid) : ''}
onChange={(v) => {
if (!v) {
setRider(null);
return;
}
const newValue = ridersList.find((r) => String(r.userid) === v);
setRider(newValue);
setFinalDeliveryList((list) =>
list.map((item) => ({ ...item, userid: newValue.userid, userfcmtoken: newValue.userfcmtoken }))
);
}}
hasClear
hasSearch
/>
</Grid>
)}
<Divider />
<HStack justify="end" gap={2} padding={3}>
<Button label="Back" variant="secondary" icon={<MdArrowBack size={16} />} onClick={() => navigate('/doormile/orders')} />
<Button
label="Finalise"
variant="primary"
isDisabled={aiMode === 0 && (!rider || !payment)}
onClick={handleManualCreateDelivery}
/>
<Selector
label="Choose Rider"
options={riderOptions}
value={rider ? String(rider.userid) : ''}
onChange={(v) => {
if (!v) {
setRider(null);
return;
}
const newValue = ridersList.find((r) => String(r.userid) === v);
setRider(newValue);
setFinalDeliveryList((list) => list.map((item) => ({ ...item, userid: newValue.userid, userfcmtoken: newValue.userfcmtoken })));
}}
hasClear
hasSearch
/>
</Grid>
)}
<Divider />
<HStack justify="end" gap={2} padding={3}>
<Button label="Back" variant="secondary" icon={<MdArrowBack size={16} />} onClick={() => navigate('/doormile/orders')} />
<Button label="Finalise" variant="primary" isDisabled={aiMode === 0 && (!rider || !payment)} onClick={handleManualCreateDelivery} />
</HStack>
</Card>
</HStack>
</DataCard>
</PageShell>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import { FaLocationDot } from 'react-icons/fa6';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { TableScroll } from 'themes/dt/primitives';
import './OrdersRedesign.css';
@@ -752,405 +753,489 @@ const MultipleOrders = () => {
<>
{(loading || btnLoading) && <Loader />}
<PageHeader
title="Create Multiple Orders"
subtitle="Bulk-create deliveries from CSV/Excel or saved customers"
action={<Badge variant={dropCust.length ? 'blue' : 'neutral'} label={`${dropCust.length} drop${dropCust.length === 1 ? '' : 's'}`} />}
/>
<PageShell>
<PageHeader
title="Create Multiple Orders"
subtitle="Bulk-create deliveries from CSV/Excel or saved customers"
action={
<Badge variant={dropCust.length ? 'blue' : 'neutral'} label={`${dropCust.length} drop${dropCust.length === 1 ? '' : 's'}`} />
}
/>
<Grid columns={{ minWidth: 420 }} gap={3}>
{/* LEFT — input fields */}
<VStack gap={3} padding={0}>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 12 }}>
Schedule &amp; Pickup
</Text>
<VStack gap={2} padding={0}>
{locationsStatus === 'loading' ? (
<Text color="secondary">Loading business locations…</Text>
) : locationsStatus === 'no-tenant' && isStaffLogin ? (
// Staff login (express-console-api.md: token tenantid
// 0/null = "Doormile's own staff... sees everything, all
// tenants") — pick which tenant this bulk order is for.
<Selector
label="Pick a tenant"
options={staffTenantOptions}
value={selectedTenantId}
onChange={(v) => setSelectedTenantId(v || '')}
placeholder="Pick a tenant to create orders for"
hasSearch
isLoading={staffTenantListLoading}
/>
) : locationsStatus === 'no-tenant' ? (
<Text style={{ color: '#b91c1c' }}>This login has no tenant assigned — sign in as a tenant user</Text>
) : locationsStatus === 'error' ? (
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} onClick={() => gettenantlocations(tenantid)}>
Could not load business locations — tap to retry
</Text>
) : locationsStatus === 'empty' ? (
<Text style={{ color: '#b45309' }}>No saved business locations for this tenant</Text>
) : tenantLocations?.length === 1 ? (
<TextInput label="Business Location" value={tenantLocations[0] ? locationDisplayName(tenantLocations[0]) : ''} isDisabled />
) : (
<Selector
label="Business Location"
options={locationOptions}
value={locationValue ? String(locationValue.tenantlocationid) : ''}
onChange={(v) => {
if (!v) {
setLocationid(0);
setLocationValue(null);
setPickCust(null);
return;
}
const value = tenantLocations.find((l) => String(l.tenantlocationid) === v);
setLocationid(value?.tenantlocationid || 0);
setLocationValue(value || null);
setPickCust(value || null);
}}
placeholder="Select Business Location"
hasSearch
hasClear
/>
)}
<Grid columns={{ minWidth: 420, repeat: 'fit' }} gap={3}>
{/* LEFT — input fields */}
<VStack gap={3} padding={0}>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 12 }}>
Schedule &amp; Pickup
</Text>
<VStack gap={2} padding={0}>
{locationsStatus === 'loading' ? (
<Text color="secondary">Loading business locations…</Text>
) : locationsStatus === 'no-tenant' && isStaffLogin ? (
// Staff login (express-console-api.md: token tenantid
// 0/null = "Doormile's own staff... sees everything, all
// tenants") — pick which tenant this bulk order is for.
<Selector
label="Pick a tenant"
options={staffTenantOptions}
value={selectedTenantId}
onChange={(v) => setSelectedTenantId(v || '')}
placeholder="Pick a tenant to create orders for"
hasSearch
isLoading={staffTenantListLoading}
/>
) : locationsStatus === 'no-tenant' ? (
<Text style={{ color: '#b91c1c' }}>This login has no tenant assigned — sign in as a tenant user</Text>
) : locationsStatus === 'error' ? (
<Text style={{ color: '#b91c1c', cursor: 'pointer' }} onClick={() => gettenantlocations(tenantid)}>
Could not load business locations — tap to retry
</Text>
) : locationsStatus === 'empty' ? (
<Text style={{ color: '#b45309' }}>No saved business locations for this tenant</Text>
) : tenantLocations?.length === 1 ? (
<TextInput
label="Business Location"
value={tenantLocations[0] ? locationDisplayName(tenantLocations[0]) : ''}
isDisabled
/>
) : (
<Selector
label="Business Location"
options={locationOptions}
value={locationValue ? String(locationValue.tenantlocationid) : ''}
onChange={(v) => {
if (!v) {
setLocationid(0);
setLocationValue(null);
setPickCust(null);
return;
}
const value = tenantLocations.find((l) => String(l.tenantlocationid) === v);
setLocationid(value?.tenantlocationid || 0);
setLocationValue(value || null);
setPickCust(value || null);
}}
placeholder="Select Business Location"
hasSearch
hasClear
/>
)}
<Grid columns={{ minWidth: 180 }} gap={2}>
<DateInput
label="Pickup Date"
size="sm"
value={startdate ? dayjs(startdate).format('YYYY-MM-DD') : undefined}
min={dayjs().format('YYYY-MM-DD')}
onChange={(v) => {
if (!v || !dayjs(v).isValid()) {
setStartdate(dayjs().format('MM-DD-YYYY'));
return;
}
const diffDays = dayjs().diff(dayjs(v), 'd');
if (diffDays <= 0) {
setStartdate(dayjs(v).format('MM-DD-YYYY'));
setSelectedtime('');
setPickupSlot(null);
} else {
opentoast('Choose an upcoming date', 'warning');
setStartdate(dayjs().format('MM-DD-YYYY'));
}
}}
<Grid columns={{ minWidth: 180, repeat: 'fit' }} gap={2}>
<DateInput
label="Pickup Date"
size="sm"
value={startdate ? dayjs(startdate).format('YYYY-MM-DD') : undefined}
min={dayjs().format('YYYY-MM-DD')}
onChange={(v) => {
if (!v || !dayjs(v).isValid()) {
setStartdate(dayjs().format('MM-DD-YYYY'));
return;
}
const diffDays = dayjs().diff(dayjs(v), 'd');
if (diffDays <= 0) {
setStartdate(dayjs(v).format('MM-DD-YYYY'));
setSelectedtime('');
setPickupSlot(null);
} else {
opentoast('Choose an upcoming date', 'warning');
setStartdate(dayjs().format('MM-DD-YYYY'));
}
}}
/>
<Selector
label="Pickup Slot"
options={slotOptions}
value={selectedtime ? dayjs(selectedtime, 'hh:mm A').format('HH:mm') : ''}
onChange={(v) => {
if (!v) {
setSelectedtime('');
setPickupSlot(null);
return;
}
if (!locationid) {
OpenToast('Please select a Business Location first.', 'warning', 3000);
return;
}
const formattedTime = dayjs(v, 'HH:mm').format('hh:mm A');
setSelectedtime(formattedTime);
const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A');
setPickupSlot(finalDateTime);
}}
placeholder="Select Pickup Slot"
hasSearch
/>
</Grid>
{pickCust ? (
<HStack
gap={2}
vAlign="center"
padding={2}
style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 10, background: '#fbfcff' }}
>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: 'linear-gradient(135deg,#0ea5e9,#0284c7)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: 12
}}
>
<FaLocationDot />
</div>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{pickCust.locationname || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{pickCust.address || '—'}
</Text>
</VStack>
</HStack>
) : (
<Text type="supporting" color="secondary">
Pickup auto-fills once a Business Location is selected.
</Text>
)}
</VStack>
</Card>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 4 }}>
Order Notes
</Text>
<Text type="supporting" color="secondary" style={{ marginBottom: 8 }}>
Applied to every order
</Text>
<TextInput
label="Special Dispatch Notes"
isLabelHidden
placeholder="Gate codes, call instructions, special cargo care…"
value={otherinstructions}
onChange={setOtherinstructions}
/>
</Card>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 2 }}>
Bulk Summary
</Text>
<Text type="supporting" color="secondary" style={{ marginBottom: 12 }}>
Live totals
</Text>
<Grid columns={{ minWidth: 140, repeat: 'fit' }} gap={2}>
<MetricTile
icon={FaRoute}
label="Distance"
value={totaldist ? `${totaldist} km` : '—'}
accent="#0ea5e9"
active={!!totaldist}
/>
<Selector
label="Pickup Slot"
options={slotOptions}
value={selectedtime ? dayjs(selectedtime, 'hh:mm A').format('HH:mm') : ''}
onChange={(v) => {
if (!v) {
setSelectedtime('');
setPickupSlot(null);
return;
}
if (!locationid) {
OpenToast('Please select a Business Location first.', 'warning', 3000);
return;
}
const formattedTime = dayjs(v, 'HH:mm').format('hh:mm A');
setSelectedtime(formattedTime);
const finalDateTime = dayjs(`${startdate} ${formattedTime}`, 'MM-DD-YYYY hh:mm A').format('YYYY-MM-DD hh:mm A');
setPickupSlot(finalDateTime);
}}
placeholder="Select Pickup Slot"
hasSearch
<MetricTile icon={FaBoxes} label="Quantity" value={totalQty || 0} accent="#10b981" active={!!totalQty} />
<MetricTile
icon={FaMoneyBillWave}
label="Cash Collect"
value={`₹${Number(totalCash).toFixed(2)}`}
accent="#f59e0b"
active={!!totalCash}
/>
<MetricTile icon={FaTruck} label="Deliveries" value={dropCust.length} accent="#8b5cf6" active={dropCust.length > 0} />
</Grid>
{pickCust ? (
<HStack gap={2} vAlign="center" padding={2} style={{ border: '1px solid #eef2f6', borderInlineStart: '3px solid #0ea5e9', borderRadius: 10, background: '#fbfcff' }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: 'linear-gradient(135deg,#0ea5e9,#0284c7)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: 12
}}
>
<FaLocationDot />
{dropCust.length > 0 && (
<div className="total-charge-badge" style={{ marginTop: 14, padding: '10px 14px' }}>
<div className="total-charge-left">
<FaReceipt className="total-charge-icon" />
<div className="total-charge-label">Total Charge</div>
</div>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{pickCust.locationname || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{pickCust.address || '—'}
</Text>
</VStack>
</HStack>
) : (
<Text type="supporting" color="secondary">
Pickup auto-fills once a Business Location is selected.
</Text>
<div className="total-charge-val" style={{ fontSize: 17 }}>
₹{Number(totalAmt).toFixed(2)}
</div>
</div>
)}
</VStack>
</Card>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 4 }}>
Order Notes
</Text>
<Text type="supporting" color="secondary" style={{ marginBottom: 8 }}>
Applied to every order
</Text>
<TextInput
label="Special Dispatch Notes"
isLabelHidden
placeholder="Gate codes, call instructions, special cargo care…"
value={otherinstructions}
onChange={setOtherinstructions}
/>
</Card>
<Card padding={3} elevation="none">
<Text weight="bold" style={{ marginBottom: 2 }}>
Bulk Summary
</Text>
<Text type="supporting" color="secondary" style={{ marginBottom: 12 }}>
Live totals
</Text>
<Grid columns={{ minWidth: 140 }} gap={2}>
<MetricTile icon={FaRoute} label="Distance" value={totaldist ? `${totaldist} km` : '—'} accent="#0ea5e9" active={!!totaldist} />
<MetricTile icon={FaBoxes} label="Quantity" value={totalQty || 0} accent="#10b981" active={!!totalQty} />
<MetricTile icon={FaMoneyBillWave} label="Cash Collect" value={`₹${Number(totalCash).toFixed(2)}`} accent="#f59e0b" active={!!totalCash} />
<MetricTile icon={FaTruck} label="Deliveries" value={dropCust.length} accent="#8b5cf6" active={dropCust.length > 0} />
</Grid>
{dropCust.length > 0 && (
<div className="total-charge-badge" style={{ marginTop: 14, padding: '10px 14px' }}>
<div className="total-charge-left">
<FaReceipt className="total-charge-icon" />
<div className="total-charge-label">Total Charge</div>
</div>
<div className="total-charge-val" style={{ fontSize: 17 }}>
₹{Number(totalAmt).toFixed(2)}
</div>
<div style={{ marginTop: 14 }}>
<Button
label={`Dispatch ${dropCust.length || ''} ${dropCust.length === 1 ? 'Order' : 'Orders'}`.trim()}
variant="primary"
icon={<FaPaperPlane size={12} />}
isDisabled={!canSubmit}
isLoading={btnLoading}
onClick={createorders}
style={{ width: '100%' }}
/>
</div>
</Card>
</VStack>
{/* RIGHT — file / drop preview */}
<Card padding={2} elevation="none" style={{ display: 'flex', flexDirection: 'column' }}>
<HStack justify="between" vAlign="center" gap={1} wrap="wrap" style={{ marginBottom: 8 }}>
<HStack gap={2} vAlign="center">
<Text weight="bold">{previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'}</Text>
<Badge variant="purple" label={previewMode === 'drops' ? dropCust.length : previewMode === 'preview' ? users.length : 0} />
{previewMode === 'drops' && <Badge variant="purple" label={`₹${Number(totalAmt).toFixed(2)} total`} />}
{previewMode === 'preview' && <Badge variant="warning" label="Awaiting Continue" />}
</HStack>
{previewMode !== 'empty' && (
<HStack gap={1}>
<Button
label="Excel / CSV"
variant="secondary"
size="sm"
icon={<MdOutlineCloudUpload size={14} />}
onClick={() => pickHeaderSource(0)}
/>
<Button
label="Selection"
variant="secondary"
size="sm"
icon={<FaUsers size={12} />}
onClick={() => pickHeaderSource(1)}
/>
</HStack>
)}
</HStack>
{fileName && (
<Text type="supporting" color="secondary" style={{ marginBottom: 8 }}>
{fileName}
</Text>
)}
<div style={{ marginTop: 14 }}>
<Button
label={`Dispatch ${dropCust.length || ''} ${dropCust.length === 1 ? 'Order' : 'Orders'}`.trim()}
variant="primary"
icon={<FaPaperPlane size={12} />}
isDisabled={!canSubmit}
isLoading={btnLoading}
onClick={createorders}
style={{ width: '100%' }}
/>
</div>
</Card>
</VStack>
{/* RIGHT — file / drop preview */}
<Card padding={2} elevation="none" style={{ display: 'flex', flexDirection: 'column' }}>
<HStack justify="between" vAlign="center" gap={1} wrap="wrap" style={{ marginBottom: 8 }}>
<HStack gap={2} vAlign="center">
<Text weight="bold">{previewMode === 'drops' ? 'Drop List' : previewMode === 'preview' ? 'File Preview' : 'Preview'}</Text>
<Badge variant="purple" label={previewMode === 'drops' ? dropCust.length : previewMode === 'preview' ? users.length : 0} />
{previewMode === 'drops' && <Badge variant="purple" label={`₹${Number(totalAmt).toFixed(2)} total`} />}
{previewMode === 'preview' && <Badge variant="warning" label="Awaiting Continue" />}
</HStack>
{previewMode !== 'empty' && (
<HStack gap={1}>
<Button label="Excel / CSV" variant="secondary" size="sm" icon={<MdOutlineCloudUpload size={14} />} onClick={() => pickHeaderSource(0)} />
<Button label="Selection" variant="secondary" size="sm" icon={<FaUsers size={12} />} onClick={() => pickHeaderSource(1)} />
{previewMode === 'preview' && users.length >= 1 && (
<HStack
justify="between"
vAlign="center"
gap={2}
padding={2}
style={{
marginBottom: 8,
background: 'rgba(245,158,11,0.06)',
border: '1.5px solid rgba(245,158,11,0.25)',
borderRadius: 10
}}
>
<VStack gap={0} padding={0}>
<Text weight="bold" style={{ color: '#b45309' }}>
Process &amp; Calculate Distances
</Text>
<Text type="supporting" style={{ color: '#b45309' }}>
Click continue to import spreadsheet rows and calculate drop charges.
</Text>
</VStack>
<Button
label="Continue"
variant="primary"
size="sm"
onClick={() => users.forEach((customer) => handleCheckboxChange1(customer))}
/>
</HStack>
)}
</HStack>
{fileName && (
<Text type="supporting" color="secondary" style={{ marginBottom: 8 }}>
{fileName}
</Text>
)}
{previewMode === 'preview' && users.length >= 1 && (
<HStack justify="between" vAlign="center" gap={2} padding={2} style={{ marginBottom: 8, background: 'rgba(245,158,11,0.06)', border: '1.5px solid rgba(245,158,11,0.25)', borderRadius: 10 }}>
<VStack gap={0} padding={0}>
<Text weight="bold" style={{ color: '#b45309' }}>
Process &amp; Calculate Distances
</Text>
<Text type="supporting" style={{ color: '#b45309' }}>
Click continue to import spreadsheet rows and calculate drop charges.
</Text>
</VStack>
<Button
label="Continue"
variant="primary"
size="sm"
onClick={() => users.forEach((customer) => handleCheckboxChange1(customer))}
/>
</HStack>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{previewMode === 'drops' && (
<TableScroll minWidth={720}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Customer</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
<TableHeaderCell>Km</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'right' }}>Charge</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{dropCust.map((customer, index) => (
<TableRow key={customer._rowkey || index}>
<TableCell>{index + 1}</TableCell>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{previewMode === 'drops' && (
<TableScroll minWidth={720}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Customer</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
<TableHeaderCell>Km</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'right' }}>Charge</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{dropCust.map((customer, index) => (
<TableRow key={customer._rowkey || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{customer.firstname}
</Text>
</TableCell>
<TableCell style={{ maxWidth: 260 }}>
{customer.latitude == null || customer.longitude == null ? (
<AddressAutocomplete
id={`drop-address-${customer._rowkey}`}
placeholder="Search address…"
value={customer.address || ''}
onChange={(text) =>
setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))
}
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
/>
) : (
<Tooltip content={customer.address || ''}>
<Text maxLines={1} style={{ maxWidth: 260 }}>
{customer.address}
</Text>
</Tooltip>
)}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{uploadType === 0 ? (
customer.quantity
) : (
<NumberInput
label="Quantity"
isLabelHidden
value={customer.quantity || undefined}
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
size="sm"
/>
)}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{uploadType === 0 ? (
`₹${Number(customer.collectionamt || 0).toFixed(2)}`
) : (
<NumberInput
label="Collection amount"
isLabelHidden
value={customer.collectionamt || undefined}
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)}
size="sm"
/>
)}
</TableCell>
<TableCell>{customer.distance || '—'}</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold">₹{Number(customer?.totalcharge || 0).toFixed(2)}</Text>
</TableCell>
<TableCell>
<IconButton
label="Remove"
tooltip="Remove"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={14} />}
onClick={() => handleCheckboxChange1(customer)}
/>
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{customer.firstname}
<Text weight="bold">Total</Text>
</TableCell>
<TableCell colSpan={2} />
<TableCell style={{ textAlign: 'center' }}>
<Text weight="bold">{totalQty}</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Text weight="bold">₹{Number(totalCash).toFixed(2)}</Text>
</TableCell>
<TableCell>
<Text weight="bold">{totaldist}</Text>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold" style={{ color: '#8b5cf6' }}>
₹{Number(totalAmt).toFixed(2)}
</Text>
</TableCell>
<TableCell style={{ maxWidth: 260 }}>
{customer.latitude == null || customer.longitude == null ? (
<AddressAutocomplete
id={`drop-address-${customer._rowkey}`}
placeholder="Search address…"
value={customer.address || ''}
onChange={(text) => setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))}
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
/>
) : (
<Tooltip content={customer.address || ''}>
<Text maxLines={1} style={{ maxWidth: 260 }}>
{customer.address}
<TableCell />
</TableRow>
</TableBody>
</Table>
</TableScroll>
)}
{previewMode === 'preview' && (
<TableScroll minWidth={640}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Contact</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u, i) => (
<TableRow key={i}>
<TableCell>{i + 1}</TableCell>
<TableCell>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{u.firstname || '—'}
</Text>
</TableCell>
<TableCell>{u.contactno || '—'}</TableCell>
<TableCell style={{ maxWidth: 240 }}>
<Tooltip content={u.address || ''}>
<Text maxLines={1} style={{ maxWidth: 240 }}>
{u.address || '—'}
</Text>
</Tooltip>
)}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{uploadType === 0 ? (
customer.quantity
) : (
<NumberInput
label="Quantity"
isLabelHidden
value={customer.quantity || undefined}
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
size="sm"
/>
)}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{uploadType === 0 ? (
`₹${Number(customer.collectionamt || 0).toFixed(2)}`
) : (
<NumberInput
label="Collection amount"
isLabelHidden
value={customer.collectionamt || undefined}
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)}
size="sm"
/>
)}
</TableCell>
<TableCell>{customer.distance || '—'}</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold">₹{Number(customer?.totalcharge || 0).toFixed(2)}</Text>
</TableCell>
<TableCell>
<IconButton label="Remove" tooltip="Remove" size="sm" variant="destructive" icon={<MdDeleteOutline size={14} />} onClick={() => handleCheckboxChange1(customer)} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell>
<Text weight="bold">Total</Text>
</TableCell>
<TableCell colSpan={2} />
<TableCell style={{ textAlign: 'center' }}>
<Text weight="bold">{totalQty}</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Text weight="bold">₹{Number(totalCash).toFixed(2)}</Text>
</TableCell>
<TableCell>
<Text weight="bold">{totaldist}</Text>
</TableCell>
<TableCell style={{ textAlign: 'right' }}>
<Text weight="bold" style={{ color: '#8b5cf6' }}>
₹{Number(totalAmt).toFixed(2)}
</Text>
</TableCell>
<TableCell />
</TableRow>
</TableBody>
</Table>
</TableScroll>
)}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>{u.quantity ?? '—'}</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
)}
{previewMode === 'preview' && (
<TableScroll minWidth={640}>
<Table density="compact" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Contact</TableHeaderCell>
<TableHeaderCell>Address</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u, i) => (
<TableRow key={i}>
<TableCell>{i + 1}</TableCell>
<TableCell>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{u.firstname || '—'}
</Text>
</TableCell>
<TableCell>{u.contactno || '—'}</TableCell>
<TableCell style={{ maxWidth: 240 }}>
<Tooltip content={u.address || ''}>
<Text maxLines={1} style={{ maxWidth: 240 }}>
{u.address || '—'}
</Text>
</Tooltip>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>{u.quantity ?? '—'}</TableCell>
<TableCell style={{ textAlign: 'center' }}>{u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableScroll>
)}
{previewMode === 'empty' && (
<VStack vAlign="center" gap={2} padding={4} style={{ minHeight: 240, background: 'rgba(24,144,255,0.03)', border: '1px dashed rgba(24,144,255,0.25)', borderRadius: 12, textAlign: 'center' }}>
<Text weight="bold">Choose a Drop Source to begin</Text>
<Text type="supporting" color="secondary" style={{ maxWidth: 360 }}>
Select a source below to import or pick your delivery customers.
</Text>
<HStack gap={2} wrap="wrap" style={{ width: '100%', maxWidth: 480, marginTop: 8 }}>
<SourceTile icon={MdOutlineCloudUpload} title="Excel / CSV" sub="Bulk upload a sheet" accent="#0ea5e9" disabled={!prereqOk} onClick={() => pickEmptySource(0)} />
<SourceTile icon={FaUsers} title="Selection" sub="Pick saved customers" accent="#8b5cf6" disabled={!prereqOk} onClick={() => pickEmptySource(1)} />
</HStack>
</VStack>
)}
</div>
</Card>
</Grid>
{previewMode === 'empty' && (
<VStack
vAlign="center"
gap={2}
padding={4}
style={{
minHeight: 240,
background: 'rgba(24,144,255,0.03)',
border: '1px dashed rgba(24,144,255,0.25)',
borderRadius: 12,
textAlign: 'center'
}}
>
<Text weight="bold">Choose a Drop Source to begin</Text>
<Text type="supporting" color="secondary" style={{ maxWidth: 360 }}>
Select a source below to import or pick your delivery customers.
</Text>
<HStack gap={2} wrap="wrap" style={{ width: '100%', maxWidth: 480, marginTop: 8 }}>
<SourceTile
icon={MdOutlineCloudUpload}
title="Excel / CSV"
sub="Bulk upload a sheet"
accent="#0ea5e9"
disabled={!prereqOk}
onClick={() => pickEmptySource(0)}
/>
<SourceTile
icon={FaUsers}
title="Selection"
sub="Pick saved customers"
accent="#8b5cf6"
disabled={!prereqOk}
onClick={() => pickEmptySource(1)}
/>
</HStack>
</VStack>
)}
</div>
</Card>
</Grid>
</PageShell>
<input
accept=".csv, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@@ -1188,7 +1273,13 @@ const MultipleOrders = () => {
const rowkey = `sel-${customer.appcustomerid}`;
const checked = dropCust.some((c) => c._rowkey === rowkey);
return (
<div key={customer.appcustomerid || index} style={{ padding: '8px 4px', borderBottom: index < filteredCustomerList.length - 1 ? '1px solid var(--color-border-subtle)' : 'none' }}>
<div
key={customer.appcustomerid || index}
style={{
padding: '8px 4px',
borderBottom: index < filteredCustomerList.length - 1 ? '1px solid var(--color-border-subtle)' : 'none'
}}
>
<CheckboxInput
label={`${customer.name} (${customer.phone})`}
description="Address not on file — add one after selecting"
@@ -1206,7 +1297,11 @@ const MultipleOrders = () => {
footer={
<LayoutFooter hasDivider>
<HStack justify="end">
<Button label={dropCust.length === 0 ? 'Close' : 'Continue'} variant={dropCust.length === 0 ? 'destructive' : 'primary'} onClick={() => setIsCustomerOpen(false)} />
<Button
label={dropCust.length === 0 ? 'Close' : 'Continue'}
variant={dropCust.length === 0 ? 'destructive' : 'primary'}
onClick={() => setIsCustomerOpen(false)}
/>
</HStack>
</LayoutFooter>
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +25,11 @@ import {
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Badge } from '@astryxdesign/core/Badge';
@@ -46,6 +46,7 @@ import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT, tint, edge, ring } from 'themes/dt/tokens';
@@ -183,7 +184,10 @@ export default function OrdersReport() {
enabled: tenantid !== 0
});
const tenantOptions = useMemo(() => (tenantlist || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || t.label || `Tenant #${t.tenantid}` })), [tenantlist]);
const tenantOptions = useMemo(
() => (tenantlist || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || t.label || `Tenant #${t.tenantid}` })),
[tenantlist]
);
const locationOptions = useMemo(
() =>
(locationlist || []).map((l) => ({
@@ -321,9 +325,25 @@ export default function OrdersReport() {
<CountCell value={sub.deliveriespending} color="#f59e0b" icon={<MdHourglassEmpty size={10} />} />
<CountCell value={sub.deliveriescancelled} color="#ef4444" icon={<MdCancel size={10} />} />
<CountCell value={sub.deliveriescompleted} color="#10b981" icon={<MdCheckCircle size={10} />} />
<MetricPill color="#f59e0b" icon={<MdCurrencyRupee size={10} />} label={Number(sub.collectionamt || 0).toFixed(2)} tooltip="Collection" minWidth={80} />
<MetricPill color="#ef4444" icon={<MdStraighten size={10} />} label={`${Number(sub.kms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill color="#10b981" icon={<MdStraighten size={10} />} label={`${Number(sub.cumulativekms || 0).toFixed(2)} km`} tooltip="Actual KMS" />
<MetricPill
color="#f59e0b"
icon={<MdCurrencyRupee size={10} />}
label={Number(sub.collectionamt || 0).toFixed(2)}
tooltip="Collection"
minWidth={80}
/>
<MetricPill
color="#ef4444"
icon={<MdStraighten size={10} />}
label={`${Number(sub.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
<MetricPill
color="#10b981"
icon={<MdStraighten size={10} />}
label={`${Number(sub.cumulativekms || 0).toFixed(2)} km`}
tooltip="Actual KMS"
/>
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={10} />}
@@ -344,108 +364,110 @@ export default function OrdersReport() {
<>
{(loading || isLoadingReports) && <Loader />}
<PageHeader
title="Orders Summary"
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
live
action={<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} setPage={setPage} placeholder="Select Zone" />}
/>
<PageShell>
<PageHeader
title="Orders Summary"
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
live
action={
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
placeholder="Select Zone"
/>
}
/>
<Grid columns={{ minWidth: 180 }} gap={3}>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<StatCard key={item.key} title={item.label} value={item.value ?? 0} icon={<Icon size={20} />} color={item.color} loading={isLoadingReports} />
);
})}
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap">
<Tooltip content="Date Filter">
<button
type="button"
onClick={() => setOpen(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
cursor: 'pointer',
background: tint('#f59e0b'),
border: `1.5px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontWeight: 800,
fontSize: 12
}}
>
<MdCalendarMonth size={14} />
Orders · {datestatus} · {dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')}
</button>
</Tooltip>
<div style={{ minWidth: 200 }}>
<Selector
label="Tenant"
isLabelHidden
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={(v) => {
if (!v) {
setTenantid(0);
setLocationid(0);
return;
}
const t = (tenantlist || []).find((x) => String(x.tenantid) === v);
setTenantid(t?.tenantid || 0);
setLocationid(0);
}}
placeholder="Select Tenant"
hasSearch
hasClear
isDisabled={!appId}
disabledMessage="Please select a Zone first!"
/>
</div>
<div style={{ minWidth: 200 }}>
<Selector
label="Location"
isLabelHidden
options={locationOptions}
value={locationid ? String(locationid) : ''}
onChange={(v) => {
if (!v) {
setLocationid(0);
return;
}
const l = (locationlist || []).find((x) => String(x.locationid) === v);
setLocationid(l?.locationid || 0);
}}
placeholder="Select Location"
hasSearch
hasClear
isDisabled={!tenantid}
disabledMessage={!appId ? 'Please select Zone and Tenant first!' : 'Please select a Tenant first!'}
/>
</div>
</HStack>
<div style={{ width: 320, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder={`Search ${isLocationGroup ? 'locations' : 'tenants'} (ctrl+k)`}
<KpiGrid>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={isLoadingReports}
/>
</div>
</HStack>
</Card>
);
})}
</KpiGrid>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
{/* No status tabs on this report (it's a tenant/location breakdown,
not a lifecycle list), so the toolbar just holds every filter
including search — same shape as hubs.js/vehicles.js when there's
nothing to pair search with. The two-row grouped Orders/Deliveries
header below (rowSpan/colSpan) stays a hand-rolled table rather
than a TanStack Table v9 column-group conversion — a materially
larger, riskier change than this pass's chrome scope. */}
<DataCard
toolbar={
<FilterToolbar>
<Button
label={`${datestatus} · ${dayjs(startdate).format('DD/MM/YY')}–${dayjs(enddate).format('DD/MM/YY')}`}
variant="secondary"
icon={<MdCalendarMonth size={16} />}
onClick={() => setOpen(true)}
/>
<Selector
label="Tenant"
isLabelHidden
options={tenantOptions}
value={tenantid ? String(tenantid) : ''}
onChange={(v) => {
if (!v) {
setTenantid(0);
setLocationid(0);
return;
}
const t = (tenantlist || []).find((x) => String(x.tenantid) === v);
setTenantid(t?.tenantid || 0);
setLocationid(0);
}}
placeholder="Select Tenant"
hasSearch
hasClear
isDisabled={!appId}
disabledMessage="Please select a Zone first!"
/>
<Selector
label="Location"
isLabelHidden
options={locationOptions}
value={locationid ? String(locationid) : ''}
onChange={(v) => {
if (!v) {
setLocationid(0);
return;
}
const l = (locationlist || []).find((x) => String(x.locationid) === v);
setLocationid(l?.locationid || 0);
}}
placeholder="Select Location"
hasSearch
hasClear
isDisabled={!tenantid}
disabledMessage={!appId ? 'Please select Zone and Tenant first!' : 'Please select a Tenant first!'}
/>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder={`Search ${isLocationGroup ? 'locations' : 'tenants'} (ctrl+k)`}
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
{isMobile ? (
<>
{!isLoadingReports && filteredRows.length === 0 && (
@@ -455,8 +477,8 @@ export default function OrdersReport() {
debouncedSearch
? 'Try a different keyword.'
: isErrorReports
? 'Something went wrong fetching the summary. Try a different filter.'
: 'Pick a zone, tenant, or date range to load the summary.'
? 'Something went wrong fetching the summary. Try a different filter.'
: 'Pick a zone, tenant, or date range to load the summary.'
}
icon={<MdAssignment size={28} />}
/>
@@ -522,7 +544,13 @@ export default function OrdersReport() {
<CountCell value={row.deliveriescompleted} color="#10b981" icon={<MdCheckCircle size={11} />} />
</MobileField>
<MobileField label="Collection" align="center">
<MetricPill color="#f59e0b" icon={<MdCurrencyRupee size={11} />} label={Number(row.collectionamt || 0).toFixed(2)} tooltip="Collection Amount" minWidth={90} />
<MetricPill
color="#f59e0b"
icon={<MdCurrencyRupee size={11} />}
label={Number(row.collectionamt || 0).toFixed(2)}
tooltip="Collection Amount"
minWidth={90}
/>
</MobileField>
<MobileField label="Amount" align="center">
<MetricPill
@@ -534,15 +562,25 @@ export default function OrdersReport() {
/>
</MobileField>
<MobileField label="KMS" align="center">
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(row.kms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
</MobileField>
<MobileField label="Actual KMS" align="center">
<MetricPill color="#10b981" icon={<MdStraighten size={11} />} label={`${Number(row.cumulativekms || 0).toFixed(2)} km`} tooltip="Actual KMS" />
<MetricPill
color="#10b981"
icon={<MdStraighten size={11} />}
label={`${Number(row.cumulativekms || 0).toFixed(2)} km`}
tooltip="Actual KMS"
/>
</MobileField>
</MobileFieldGrid>
{isOpen && (
<div style={{ marginTop: 12 }}>
<div style={{ marginTop: 'var(--spacing-6)' }}>
<RiderBreakdown />
</div>
)}
@@ -580,12 +618,24 @@ export default function OrdersReport() {
<TableHeaderCell rowSpan={2}>Action</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#f59e0b' }}>Pending</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>Cancelled</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#10b981' }}>Completed</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#f59e0b' }}>Pending</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#ef4444' }}>Cancelled</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>Completed</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#f59e0b' }}>
Pending
</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>
Cancelled
</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#10b981' }}>
Completed
</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#f59e0b' }}>
Pending
</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#ef4444' }}>
Cancelled
</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>
Completed
</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -601,8 +651,8 @@ export default function OrdersReport() {
debouncedSearch
? 'Try a different keyword.'
: isErrorReports
? 'Something went wrong fetching the summary. Try a different filter.'
: 'Pick a zone, tenant, or date range to load the summary.'
? 'Something went wrong fetching the summary. Try a different filter.'
: 'Pick a zone, tenant, or date range to load the summary.'
}
icon={<MdAssignment size={28} />}
/>
@@ -662,13 +712,29 @@ export default function OrdersReport() {
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MetricPill color="#f59e0b" icon={<MdCurrencyRupee size={11} />} label={Number(row.collectionamt || 0).toFixed(2)} tooltip="Collection Amount" minWidth={90} />
<MetricPill
color="#f59e0b"
icon={<MdCurrencyRupee size={11} />}
label={Number(row.collectionamt || 0).toFixed(2)}
tooltip="Collection Amount"
minWidth={90}
/>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<VStack gap={1} padding={0} vAlign="center">
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(row.kms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill color="#10b981" icon={<MdStraighten size={11} />} label={`${Number(row.cumulativekms || 0).toFixed(2)} km`} tooltip="Actual KMS" />
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
<MetricPill
color="#10b981"
icon={<MdStraighten size={11} />}
label={`${Number(row.cumulativekms || 0).toFixed(2)} km`}
tooltip="Actual KMS"
/>
</VStack>
</TableCell>
@@ -741,8 +807,8 @@ export default function OrdersReport() {
</HStack>
</>
)}
</Card>
</VStack>
</DataCard>
</PageShell>
<DateFilterDialog
open={open}

View File

@@ -5,7 +5,6 @@ import { MdCalendarMonth } from 'react-icons/md';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { DateInput } from '@astryxdesign/core/DateInput';
@@ -15,6 +14,7 @@ import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { fetchDeliveries } from 'pages/api/api';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import ProfitabilitySection, {
BASE_REVENUE,
BASE_KM_LIMIT,
@@ -139,19 +139,19 @@ const Profitability = () => {
<>
{isLoading && <Loader />}
<PageHeader
title="Profitability"
subtitle={`Live · ${dayjs(selectedDate).format('DD MMM YYYY')}`}
live
action={
<HStack gap={2} vAlign="center">
<MdCalendarMonth size={16} color={BRAND} />
<DateInput label="Report date" isLabelHidden size="sm" value={selectedDate} onChange={(v) => v && setSelectedDate(v)} />
</HStack>
}
/>
<PageShell>
<PageHeader
title="Profitability"
subtitle={`Live · ${dayjs(selectedDate).format('DD MMM YYYY')}`}
live
action={
<HStack gap={2} vAlign="center">
<MdCalendarMonth size={16} color={BRAND} />
<DateInput label="Report date" isLabelHidden size="sm" value={selectedDate} onChange={(v) => v && setSelectedDate(v)} />
</HStack>
}
/>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
{!isLoading && riders.length === 0 ? (
<Card padding={6}>
<EmptyState title={`No deliveries for ${dayjs(selectedDate).format('DD MMM YYYY')}`} />
@@ -159,7 +159,7 @@ const Profitability = () => {
) : (
<ProfitabilitySection riders={riders} totalDailyProfit={totalDailyProfit} selectedDate={selectedDate} />
)}
</VStack>
</PageShell>
</>
);
};

View File

@@ -23,11 +23,11 @@ import {
var utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Divider } from '@astryxdesign/core/Divider';
@@ -43,6 +43,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import RidersRoutes from './RidersRoutes';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
@@ -257,7 +258,12 @@ export default function RidersSummary() {
<CountCell value={sub.deliveriespending} color="#f59e0b" icon={<MdHourglassEmpty size={10} />} />
<CountCell value={sub.deliveriescompleted} color="#10b981" icon={<MdCheckCircle size={10} />} />
<CountCell value={sub.deliveriescancelled} color="#ef4444" icon={<MdCancel size={10} />} />
<MetricPill color="#ef4444" icon={<MdStraighten size={10} />} label={`${Number(sub.kms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill
color="#ef4444"
icon={<MdStraighten size={10} />}
label={`${Number(sub.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
<MetricPill
color="#10b981"
icon={<MdStraighten size={10} />}
@@ -267,7 +273,9 @@ export default function RidersSummary() {
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={10} />}
label={formatNumberToRupees(Math.max(Number(sub.charges) || 0, Number(sub.deliveryamt) || 0)).replace('₹', '').trim()}
label={formatNumberToRupees(Math.max(Number(sub.charges) || 0, Number(sub.deliveryamt) || 0))
.replace('₹', '')
.trim()}
tooltip="Total Amount"
minWidth={100}
/>
@@ -283,68 +291,69 @@ export default function RidersSummary() {
<>
{(isLoadingReports || loading) && <Loader />}
<PageHeader
title="Riders Summary"
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
live
action={<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} placeholder="Select Zone" />}
/>
<PageShell>
<PageHeader
title="Riders Summary"
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
live
action={<LocationAutocomplete locaName={locaName} setAppId={setAppId} setLocoName={setLocoName} placeholder="Select Zone" />}
/>
<Grid columns={{ minWidth: 180 }} gap={3}>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<StatCard key={item.key} title={item.label} value={item.value ?? 0} icon={<Icon size={20} />} color={item.color} loading={isLoadingReports} />
);
})}
</Grid>
<KpiGrid>
{KPI_META.map((item) => {
const Icon = item.icon;
return (
<StatCard
key={item.key}
title={item.label}
value={item.value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={isLoadingReports}
/>
);
})}
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap">
<AccentAvatar color={BRAND} size={32}>
<MdDirectionsBike size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Orders · {datestatus}
</Text>
<Text weight="bold">
{filteredRows.length} riders · {stats.orders} orders
</Text>
</VStack>
<Tooltip content="Date Filter">
<button
type="button"
{/* No status tabs on this report — toolbar holds the date range and
search, same shape as hubs.js/vehicles.js when there's nothing to
pair search with. */}
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdDirectionsBike size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
Orders · {datestatus}
</Text>
<Text weight="bold">
{filteredRows.length} riders · {stats.orders} orders
</Text>
</VStack>
<Button
label={`${dayjs(startdate).format('DD/MM/YY')} – ${dayjs(enddate).format('DD/MM/YY')}`}
variant="secondary"
size="sm"
icon={<MdCalendarMonth size={14} />}
onClick={() => setOpen(true)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 999,
cursor: 'pointer',
background: tint('#f59e0b'),
border: `1.5px solid ${edge('#f59e0b')}`,
color: '#f59e0b',
fontWeight: 800,
fontSize: 12,
marginInlineStart: 8
}}
>
<MdCalendarMonth size={14} />
{dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')}
</button>
</Tooltip>
</HStack>
<div style={{ width: 300, maxWidth: '100%' }}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} placeholder="Search riders (ctrl+k)" />
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
/>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search riders (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
{isMobile ? (
<MobileCardList scroll>
{(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? (
@@ -379,7 +388,14 @@ export default function RidersSummary() {
</VStack>
</HStack>
<HStack gap={0.75}>
<IconButton label="View planned route" tooltip="View planned route" size="sm" variant="ghost" icon={<MdMap size={14} />} onClick={() => openRoutePreview(row)} />
<IconButton
label="View planned route"
tooltip="View planned route"
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
onClick={() => openRoutePreview(row)}
/>
<IconButton
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
@@ -406,7 +422,12 @@ export default function RidersSummary() {
<CountCell value={row.cancelled} color="#ef4444" icon={<MdCancel size={11} />} />
</MobileField>
<MobileField label="KMS">
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(row.riderkms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.riderkms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
</MobileField>
<MobileField label="Amount" full>
<MetricPill
@@ -497,7 +518,12 @@ export default function RidersSummary() {
</TableCell>
<TableCell>
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(row.riderkms || 0).toFixed(2)} km`} tooltip="KMS" />
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.riderkms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
</TableCell>
<TableCell>
@@ -512,7 +538,14 @@ export default function RidersSummary() {
<TableCell>
<HStack gap={0.75} justify="end">
<IconButton label="View planned route" tooltip="View planned route" size="sm" variant="ghost" icon={<MdMap size={14} />} onClick={() => openRoutePreview(row)} />
<IconButton
label="View planned route"
tooltip="View planned route"
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
onClick={() => openRoutePreview(row)}
/>
<IconButton
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
@@ -566,8 +599,8 @@ export default function RidersSummary() {
</HStack>
</>
)}
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog
isOpen={mapOpen}

View File

@@ -46,7 +46,11 @@ export default function RiderSubstitution({
handleAssignSubstitute,
onFinalizeSuccess,
selectedDate,
setSelectedDate
setSelectedDate,
// When true, skips this component's own Card wrappers — for use inside a
// parent DataCard (riders.js's Substitutes tab), which already provides the
// card chrome. Rendering both would double-border the region.
bare = false
}) {
const isMobile = useIsNarrowViewport();
const [selectedBatch, setSelectedBatch] = useState('all');
@@ -172,183 +176,210 @@ export default function RiderSubstitution({
/>
);
return (
const filterRow = (
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap">
<Text weight="bold" color="secondary">
Shift Batch:
</Text>
<SegmentedControl label="Shift batch" value={selectedBatch} onChange={setSelectedBatch} size="sm">
{BATCH_OPTIONS.map((b) => (
<SegmentedControlItem key={b.value} value={b.value} label={b.label} />
))}
</SegmentedControl>
</HStack>
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setSelectedDate(dayjs(v))}
/>
</HStack>
);
const tableRegion = (
<>
<Card padding={3} elevation="none" style={{ marginBottom: 16 }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap">
<Text weight="bold" color="secondary">
Shift Batch:
</Text>
<SegmentedControl label="Shift batch" value={selectedBatch} onChange={setSelectedBatch} size="sm">
{BATCH_OPTIONS.map((b) => (
<SegmentedControlItem key={b.value} value={b.value} label={b.label} />
))}
</SegmentedControl>
</HStack>
<DateInput
label="Select Date"
isLabelHidden
size="sm"
value={selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setSelectedDate(dayjs(v))}
/>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
{isMobile ? (
filteredRows?.length === 0 ? (
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
) : (
<MobileCardList scroll>
{filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime;
return (
<MobileCard
key={row.userid ?? index}
accent={statusMeta.color}
header={
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11
}}
>
#{row?.userid}
</div>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={11} />} label={statusMeta.label} />
</HStack>
</HStack>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.username || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</VStack>
}
>
<MobileFieldGrid columns={1}>
<MobileField label="Substitute" full>
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
</MobileField>
</MobileFieldGrid>
</MobileCard>
);
})}
</MobileCardList>
)
{isMobile ? (
filteredRows?.length === 0 ? (
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
) : (
<TableScroll minWidth={1200}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
<TableHeaderCell>Substitute Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows?.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
</TableCell>
</TableRow>
)}
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime;
return (
<TableRow key={row.userid ?? index}>
<TableCell>
<MobileCardList scroll>
{filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime;
return (
<MobileCard
key={row.userid ?? index}
accent={statusMeta.color}
header={
<VStack gap={2} padding={0}>
<HStack justify="between" vAlign="center" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
</TableCell>
<TableCell>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
fontSize: 11
}}
>
#{row?.userid}
</div>
</TableCell>
<TableCell>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold">{row.username || '—'}</Text>
<Text type="supporting" color="secondary">
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={11} />} label={statusMeta.label} />
</HStack>
</HStack>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.username || '—'}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</VStack>
}
>
<MobileFieldGrid columns={1}>
<MobileField label="Substitute" full>
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
</MobileField>
</MobileFieldGrid>
</MobileCard>
);
})}
</MobileCardList>
)
) : (
<TableScroll minWidth={1200}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
<TableHeaderCell>Substitute Rider</TableHeaderCell>
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows?.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
</TableCell>
</TableRow>
)}
<TableCell style={{ textAlign: 'center' }}>
<MdArrowForward size={18} color={DT.textSecondary} />
</TableCell>
<TableCell style={{ minWidth: 220 }}>{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}</TableCell>
{filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => {
const statusMeta = getRowStatusMeta(row);
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime;
return (
<TableRow key={row.userid ?? index}>
<TableCell>
<Text type="supporting" weight="bold" color="secondary">
{String(index + 1).padStart(2, '0')}
</Text>
</TableCell>
<TableCell>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}}
>
#{row?.userid}
</div>
</TableCell>
<TableCell>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold">{row.username || '—'}</Text>
<Text type="supporting" color="secondary">
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableScroll>
)}
{hasAssignments && (
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
<Button label={isFinalizing ? 'Saving...' : 'Finalize Substitutions'} variant="primary" onClick={handleFinalize} isLoading={isFinalizing} />
</HStack>
)}
</Card>
<TableCell style={{ textAlign: 'center' }}>
<MdArrowForward size={18} color={DT.textSecondary} />
</TableCell>
<TableCell style={{ minWidth: 220 }}>
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableScroll>
)}
{hasAssignments && (
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
<Button
label={isFinalizing ? 'Saving...' : 'Finalize Substitutions'}
variant="primary"
onClick={handleFinalize}
isLoading={isFinalizing}
/>
</HStack>
)}
</>
);
return (
<>
{bare ? (
<VStack gap={0} padding={0} paddingBlock={3}>
{filterRow}
</VStack>
) : (
<Card padding={3} elevation="none" style={{ marginBottom: 16 }}>
{filterRow}
</Card>
)}
{bare ? (
tableRegion
) : (
<Card padding={0} elevation="none" style={{ overflow: 'hidden' }}>
{tableRegion}
</Card>
)}
</>
);
}

View File

@@ -14,6 +14,7 @@ import { Selector } from '@astryxdesign/core/Selector';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { createMiler, getHubs } from 'pages/api/doormileApi';
import { getTenants, fetchAppLocations } from 'pages/api/api';
import { DT } from 'themes/dt/tokens';
@@ -58,10 +59,16 @@ const Createrider = () => {
queryFn: fetchAppLocations
});
const tenantOptions = useMemo(() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })), [tenants]);
const tenantOptions = useMemo(
() => (tenants || []).map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` })),
[tenants]
);
const hubOptions = useMemo(() => (hubs || []).map((h) => ({ value: String(h.hubid), label: h.hubname || `Hub #${h.hubid}` })), [hubs]);
const locationOptions = useMemo(
() => (locations || []).filter((l) => l.applocationid).map((l) => ({ value: String(l.applocationid), label: l.locationname || `Location #${l.applocationid}` })),
() =>
(locations || [])
.filter((l) => l.applocationid)
.map((l) => ({ value: String(l.applocationid), label: l.locationname || `Location #${l.applocationid}` })),
[locations]
);
@@ -125,70 +132,92 @@ const Createrider = () => {
<>
{createMilerMutation.isPending && <Loader />}
<PageHeader
title="Create Rider"
subtitle="Add a miler-app login"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdDirectionsBike size={20} />
</AccentAvatar>
}
/>
<PageShell>
<PageHeader
title="Create Rider"
subtitle="Add a miler-app login"
action={
<AccentAvatar color={BRAND} selected size={40}>
<MdDirectionsBike size={20} />
</AccentAvatar>
}
/>
<Card padding={4} elevation="none" style={{ marginTop: 16 }}>
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput label="Display Name" placeholder="e.g. Murali S" value={displayname} onChange={setDisplayname} isRequired />
<TextInput label="Login Name" placeholder="Login name used on the miler app" value={authname} onChange={setAuthname} isRequired />
<TextInput
label="Phone Number"
placeholder="10-digit mobile number"
value={mobilenumber}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setMobilenumber(digits);
}}
isRequired
/>
<TextInput label="Email Address" type="email" placeholder="Email Address" value={emailaddress} onChange={setEmailaddress} isRequired />
<TextInput label="Password" type="password" placeholder="Miler-app login password" value={password} onChange={setPassword} isRequired />
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={vehicletype} onChange={setVehicletype} />
{isDoormileStaff && (
<Card padding={4} elevation="none">
<VStack gap={4} padding={0}>
<Grid columns={{ minWidth: 260, repeat: 'fit' }} gap={3}>
<TextInput label="Display Name" placeholder="e.g. Murali S" value={displayname} onChange={setDisplayname} isRequired />
<TextInput
label="Login Name"
placeholder="Login name used on the miler app"
value={authname}
onChange={setAuthname}
isRequired
/>
<TextInput
label="Phone Number"
placeholder="10-digit mobile number"
value={mobilenumber}
onChange={(v) => {
const digits = v.replace(/\D/g, '');
if (digits.length <= 10) setMobilenumber(digits);
}}
isRequired
/>
<TextInput
label="Email Address"
type="email"
placeholder="Email Address"
value={emailaddress}
onChange={setEmailaddress}
isRequired
/>
<TextInput
label="Password"
type="password"
placeholder="Miler-app login password"
value={password}
onChange={setPassword}
isRequired
/>
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={vehicletype} onChange={setVehicletype} />
{isDoormileStaff && (
<Selector
label="Tenant"
options={tenantOptions}
value={selectedTenantId}
onChange={setSelectedTenantId}
placeholder="Choose tenant"
hasSearch
isRequired
/>
)}
<Selector
label="Tenant"
options={tenantOptions}
value={selectedTenantId}
onChange={setSelectedTenantId}
placeholder="Choose tenant"
label="City"
options={locationOptions}
value={selectedLocationId}
onChange={setSelectedLocationId}
placeholder="Choose city"
hasSearch
isRequired
/>
)}
<Selector
label="City"
options={locationOptions}
value={selectedLocationId}
onChange={setSelectedLocationId}
placeholder="Choose city"
hasSearch
isRequired
/>
<Selector
label="Hub"
options={hubOptions}
value={selectedHubId}
onChange={setSelectedHubId}
placeholder="Choose hub (visibility on hub console)"
hasSearch
isOptional
/>
</Grid>
<Selector
label="Hub"
options={hubOptions}
value={selectedHubId}
onChange={setSelectedHubId}
placeholder="Choose hub (visibility on hub console)"
hasSearch
isOptional
/>
</Grid>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createMilerMutation.isPending} />
</HStack>
</VStack>
</Card>
<HStack justify="end">
<Button label="Create" variant="primary" onClick={createprofile} isLoading={createMilerMutation.isPending} />
</HStack>
</VStack>
</Card>
</PageShell>
</>
);
};

View File

@@ -14,6 +14,7 @@ import { Selector } from '@astryxdesign/core/Selector';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { getMiler, updateMiler, getAdminTenant } from 'pages/api/doormileApi';
import { fetchAppLocations as fetchZoneLocations } from 'pages/api/api';
import { DT } from 'themes/dt/tokens';
@@ -118,89 +119,93 @@ const EditRider = () => {
<>
{loading && <Loader />}
<PageHeader
title="Edit Rider"
subtitle={riderdata?.displayname || 'Update miler-app login'}
action={
<Button
label="Back to Riders"
variant="secondary"
icon={<MdArrowBack size={16} />}
onClick={() => {
setRiderdata(null);
navigate('/doormile/riders');
}}
/>
}
/>
<Card padding={0} elevation="none" style={{ marginTop: 16 }}>
<HStack gap={2} vAlign="center" padding={3} style={{ borderBottom: `1px solid ${DT.divider}`, backgroundColor: DT.surfaceAlt }}>
<AccentAvatar color={BRAND} selected size={32}>
<MdDirectionsBike size={16} />
</AccentAvatar>
<Text weight="bold">Contact Information</Text>
</HStack>
<VStack gap={0} padding={4}>
<Grid columns={{ minWidth: 260 }} gap={3}>
<TextInput
label="Display Name"
placeholder="Name"
value={riderdata?.displayname || ''}
onChange={(v) => setRiderdata({ ...riderdata, displayname: v })}
/>
<TextInput label="Login Name" value={riderdata?.authname || ''} isDisabled />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={riderdata?.phone || ''}
onChange={(v) => setRiderdata({ ...riderdata, phone: v.replace(/\D/g, '') })}
/>
<TextInput
label="Email Address"
type="email"
placeholder="Email Address"
value={riderdata?.email || ''}
onChange={(v) => setRiderdata({ ...riderdata, email: v })}
/>
<TextInput
label="Identification No"
placeholder="Identification No"
value={riderdata?.identificationno || ''}
onChange={(v) => setRiderdata({ ...riderdata, identificationno: v })}
/>
<Selector
label="City / Zone"
options={locationOptions}
value={riderdata?.applocationid ? String(riderdata.applocationid) : ''}
onChange={(v) => {
const loc = (partnerlist || []).find((l) => String(l.applocationid) === v);
setRiderdata({ ...riderdata, applocationid: loc?.applocationid });
<PageShell>
<PageHeader
title="Edit Rider"
subtitle={riderdata?.displayname || 'Update miler-app login'}
action={
<Button
label="Back to Riders"
variant="secondary"
icon={<MdArrowBack size={16} />}
onClick={() => {
setRiderdata(null);
navigate('/doormile/riders');
}}
placeholder={riderdata?.city || 'Choose city / zone'}
hasSearch
/>
</Grid>
{/* Address/Suburb/City/State removed — GET /admin/milers has no such
fields at all (confirmed live), so they always loaded blank and
updateRider() never submitted them. Charges/Bank Details/Vehicle
Details sections removed — PUT /admin/milers/:id has no matching
fields for any of them (no shift/basefare/bank/vehicle sub-object on
a miler), so nothing typed into them ever reached the server. */}
</VStack>
</Card>
}
/>
<Card
padding={3}
elevation="none"
style={{ position: 'sticky', bottom: 0, marginTop: 16, backgroundColor: DT.surfaceAlt, zIndex: 10 }}
>
<HStack gap={2} justify="end">
<Button label="Back to Riders" variant="secondary" icon={<MdArrowBack size={16} />} onClick={() => navigate('/doormile/riders')} />
<Button label="Update" variant="primary" onClick={updateRider} isLoading={loading} />
</HStack>
</Card>
<Card padding={0} elevation="none">
<HStack gap={2} vAlign="center" padding={3} style={{ borderBottom: `1px solid ${DT.divider}`, backgroundColor: DT.surfaceAlt }}>
<AccentAvatar color={BRAND} selected size={32}>
<MdDirectionsBike size={16} />
</AccentAvatar>
<Text weight="bold">Contact Information</Text>
</HStack>
<VStack gap={0} padding={4}>
<Grid columns={{ minWidth: 260, repeat: 'fit' }} gap={3}>
<TextInput
label="Display Name"
placeholder="Name"
value={riderdata?.displayname || ''}
onChange={(v) => setRiderdata({ ...riderdata, displayname: v })}
/>
<TextInput label="Login Name" value={riderdata?.authname || ''} isDisabled />
<TextInput
label="Phone Number"
placeholder="Phone Number"
value={riderdata?.phone || ''}
onChange={(v) => setRiderdata({ ...riderdata, phone: v.replace(/\D/g, '') })}
/>
<TextInput
label="Email Address"
type="email"
placeholder="Email Address"
value={riderdata?.email || ''}
onChange={(v) => setRiderdata({ ...riderdata, email: v })}
/>
<TextInput
label="Identification No"
placeholder="Identification No"
value={riderdata?.identificationno || ''}
onChange={(v) => setRiderdata({ ...riderdata, identificationno: v })}
/>
<Selector
label="City / Zone"
options={locationOptions}
value={riderdata?.applocationid ? String(riderdata.applocationid) : ''}
onChange={(v) => {
const loc = (partnerlist || []).find((l) => String(l.applocationid) === v);
setRiderdata({ ...riderdata, applocationid: loc?.applocationid });
}}
placeholder={riderdata?.city || 'Choose city / zone'}
hasSearch
/>
</Grid>
{/* Address/Suburb/City/State removed — GET /admin/milers has no
such fields at all (confirmed live), so they always loaded blank
and updateRider() never submitted them. Charges/Bank Details/
Vehicle Details sections removed — PUT /admin/milers/:id has no
matching fields for any of them (no shift/basefare/bank/vehicle
sub-object on a miler), so nothing typed into them ever reached
the server. */}
</VStack>
</Card>
<Card padding={3} elevation="none" style={{ position: 'sticky', bottom: 0, backgroundColor: DT.surfaceAlt, zIndex: 10 }}>
<HStack gap={2} justify="end">
<Button
label="Back to Riders"
variant="secondary"
icon={<MdArrowBack size={16} />}
onClick={() => navigate('/doormile/riders')}
/>
<Button label="Update" variant="primary" onClick={updateRider} isLoading={loading} />
</HStack>
</Card>
</PageShell>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,8 @@ import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, M
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
@@ -20,7 +18,17 @@ import Loader from 'components/Loader';
import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { getTripsheets, createTripsheet, addTripsheetItem, dispatchTripsheet, arriveTripsheet, getHubs, getVehicles, getMilers } from 'pages/api/doormileApi';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import {
getTripsheets,
createTripsheet,
addTripsheetItem,
dispatchTripsheet,
arriveTripsheet,
getHubs,
getVehicles,
getMilers
} from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
@@ -128,86 +136,88 @@ const Tripsheets = () => {
<>
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
<PageHeader
title="Tripsheets"
subtitle="Live · Hub-to-Hub Transport"
live
action={<Button label="New Tripsheet" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<PageShell>
<PageHeader
title="Tripsheets"
subtitle="Live · Hub-to-Hub Transport"
live
action={<Button label="New Tripsheet" variant="primary" icon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} />}
/>
<Grid columns={{ minWidth: 220, max: 2, repeat: 'fit' }} gap={3}>
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
</Grid>
<KpiGrid>
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
</KpiGrid>
<Card padding={0} elevation="none" style={{ marginTop: 16, overflow: 'hidden' }}>
<TableScroll minWidth={920}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Source Hub</TableHeaderCell>
<TableHeaderCell>Destination Hub</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={5} />}
{tripsheets.length === 0 && !isLoading ? (
<DataCard>
<TableScroll minWidth={920}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableCell colSpan={5}>
<EmptyState
title="No tripsheets to show"
description="Create a tripsheet to move consignments between hubs."
icon={<MdOutlineLocalShipping size={28} />}
/>
</TableCell>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Source Hub</TableHeaderCell>
<TableHeaderCell>Destination Hub</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
) : (
tripsheets.map((row, index) => (
<TableRow key={row.tripsheetid || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
<TableCell>
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Add item"
tooltip="Add item"
size="sm"
variant="ghost"
icon={<MdPlaylistAdd size={16} />}
onClick={() => setItemDialogTrip(row)}
/>
<IconButton
label="Dispatch"
tooltip="Dispatch"
size="sm"
variant="ghost"
icon={<MdOutlineFlightTakeoff size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
/>
<IconButton
label="Arrive"
tooltip="Arrive"
size="sm"
variant="ghost"
icon={<MdOutlineFlightLand size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
/>
</HStack>
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={5} />}
{tripsheets.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={5}>
<EmptyState
title="No tripsheets to show"
description="Create a tripsheet to move consignments between hubs."
icon={<MdOutlineLocalShipping size={28} />}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</Card>
) : (
tripsheets.map((row, index) => (
<TableRow key={row.tripsheetid || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
<TableCell>
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Add item"
tooltip="Add item"
size="sm"
variant="ghost"
icon={<MdPlaylistAdd size={16} />}
onClick={() => setItemDialogTrip(row)}
/>
<IconButton
label="Dispatch"
tooltip="Dispatch"
size="sm"
variant="ghost"
icon={<MdOutlineFlightTakeoff size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
/>
<IconButton
label="Arrive"
tooltip="Arrive"
size="sm"
variant="ghost"
icon={<MdOutlineFlightLand size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
/>
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={480} purpose="form">
<Layout
@@ -292,13 +302,7 @@ const Tripsheets = () => {
}
content={
<LayoutContent padding={4}>
<TextInput
label="Consignment ID"
value={itemConsignmentId}
onChange={setItemConsignmentId}
hasAutoFocus
isRequired
/>
<TextInput label="Consignment ID" value={itemConsignmentId} onChange={setItemConsignmentId} hasAutoFocus isRequired />
</LayoutContent>
}
footer={

View File

@@ -4,7 +4,6 @@ import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatter
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
@@ -21,6 +20,7 @@ import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
import { DT, STATUS } from 'themes/dt/tokens';
@@ -59,7 +59,9 @@ const Vehicles = () => {
const rows = useMemo(() => {
if (!debouncedSearch) return vehicles;
const q = debouncedSearch.toLowerCase().trim();
return vehicles.filter((row) => [row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
return vehicles.filter((row) =>
[row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
);
}, [vehicles, debouncedSearch]);
const stats = useMemo(() => {
@@ -150,38 +152,42 @@ const Vehicles = () => {
<>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
<PageHeader
title="Vehicles"
subtitle="Live · Fleet Directory"
live
action={<Button label="New Vehicle" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<PageShell>
<PageHeader
title="Vehicles"
subtitle="Live · Fleet Directory"
live
action={<Button label="New Vehicle" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} />
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} />
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
</Grid>
<KpiGrid>
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} />
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} />
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
</KpiGrid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{vehicles.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search vehicles (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<DataCard
toolbar={
<FilterToolbar max={2}>
<HStack vAlign="center">
<Text weight="bold">
{vehicles.length} total · {rows.length} shown
</Text>
</HStack>
<HStack justify="end">
<VStack width={260} padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search vehicles (ctrl+k)"
/>
</VStack>
</HStack>
</FilterToolbar>
}
>
<TableScroll minWidth={960}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
@@ -244,13 +250,13 @@ const Vehicles = () => {
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
</DataCard>
</PageShell>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<DialogHeader title={form.vehicleid ? 'Edit Vehicle' : 'New Vehicle'} />
<VStack gap={4} padding={4}>
<Grid columns={{ minWidth: 240 }} gap={3}>
<Grid columns={{ minWidth: 240, repeat: 'fit' }} gap={3}>
<TextInput label="Vehicle No" value={form.vehicleno} onChange={set('vehicleno')} isRequired />
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={form.vehicletype} onChange={set('vehicletype')} />
<Selector
@@ -275,7 +281,7 @@ const Vehicles = () => {
/>
</Grid>
<Grid columns={{ minWidth: 180 }} gap={3}>
<Grid columns={{ minWidth: 180, repeat: 'fit' }} gap={3}>
<NumberInput
label="Max Weight (kg)"
value={form.maxweight === '' ? undefined : Number(form.maxweight)}

View File

@@ -16,6 +16,7 @@ import { Divider } from '@astryxdesign/core/Divider';
import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout';
import { getusers } from 'pages/api/api';
import { updateProfilePassword } from 'pages/api/doormileApi';
import { DT } from 'themes/dt/tokens';
@@ -110,73 +111,75 @@ const ViewProfile = () => {
<>
{isLoading && <Loader />}
<PageHeader title="My Profile" subtitle="Account & Security" />
<PageShell>
<PageHeader title="My Profile" subtitle="Account & Security" />
<Card padding={4} elevation="none" style={{ marginTop: 16, marginBottom: 20 }}>
<HStack gap={3} vAlign="center" wrap="wrap">
<Avatar name={fullname} size={72} tooltip={false} />
<VStack gap={2} padding={0}>
<Text type="heading" weight="bold">
{fullname}
</Text>
<HStack gap={2} vAlign="center" wrap="wrap">
{roleLabel !== '—' && <Badge variant="neutral" icon={<MdVerifiedUser size={14} />} label={roleLabel} />}
{(userData?.id != null || userData?.userid != null) && (
<Text color="secondary" weight="semibold">
ID #{userData.id ?? userData.userid}
</Text>
)}
<HStack gap={1} vAlign="center">
<MdLocationOn size={15} color={DT.textSecondary} />
<Text color="secondary" weight="semibold">
{tenantLabel}
</Text>
<Card padding={4} elevation="none">
<HStack gap={3} vAlign="center" wrap="wrap">
<Avatar name={fullname} size={72} tooltip={false} />
<VStack gap={2} padding={0}>
<Text type="heading" weight="bold">
{fullname}
</Text>
<HStack gap={2} vAlign="center" wrap="wrap">
{roleLabel !== '—' && <Badge variant="neutral" icon={<MdVerifiedUser size={14} />} label={roleLabel} />}
{(userData?.id != null || userData?.userid != null) && (
<Text color="secondary" weight="semibold">
ID #{userData.id ?? userData.userid}
</Text>
)}
<HStack gap={1} vAlign="center">
<MdLocationOn size={15} color={DT.textSecondary} />
<Text color="secondary" weight="semibold">
{tenantLabel}
</Text>
</HStack>
</HStack>
</HStack>
</VStack>
</HStack>
</Card>
</VStack>
</HStack>
</Card>
<Grid columns={{ minWidth: 320 }} gap={3}>
<SectionCard title="Account" icon={MdBadge}>
<VStack gap={4} padding={0}>
<InfoField icon={MdPerson} label="User Name" value={userData?.name || userData?.fullname} />
<InfoField icon={MdBadge} label="User ID" value={userData?.id ?? userData?.userid} />
<InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" />
<InfoField icon={MdVerifiedUser} label="Role" value={roleLabel} />
<InfoField icon={MdLocationOn} label="Tenant" value={tenantLabel} accent="#14b8a6" />
</VStack>
</SectionCard>
<Grid columns={{ minWidth: 320, repeat: 'fit' }} gap={3}>
<SectionCard title="Account" icon={MdBadge}>
<VStack gap={4} padding={0}>
<InfoField icon={MdPerson} label="User Name" value={userData?.name || userData?.fullname} />
<InfoField icon={MdBadge} label="User ID" value={userData?.id ?? userData?.userid} />
<InfoField icon={MdMail} label="E-Mail" value={userData?.email} accent="#0ea5e9" />
<InfoField icon={MdVerifiedUser} label="Role" value={roleLabel} />
<InfoField icon={MdLocationOn} label="Tenant" value={tenantLabel} accent="#14b8a6" />
</VStack>
</SectionCard>
<SectionCard title="Security" icon={MdShield}>
<VStack gap={3} padding={0}>
<TextInput
label="Current Password"
type="password"
value={currentPassword}
onChange={setCurrentPassword}
placeholder="Current password"
/>
<TextInput label="New Password" type="password" value={newPassword} onChange={setNewPassword} placeholder="New password" />
<TextInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="Confirm new password"
/>
<Divider />
<Button
label="Update Password"
variant="primary"
icon={<MdLock size={16} />}
onClick={handleChangePassword}
isLoading={changePasswordMutation.isPending}
style={{ alignSelf: 'flex-start' }}
/>
</VStack>
</SectionCard>
</Grid>
<SectionCard title="Security" icon={MdShield}>
<VStack gap={3} padding={0}>
<TextInput
label="Current Password"
type="password"
value={currentPassword}
onChange={setCurrentPassword}
placeholder="Current password"
/>
<TextInput label="New Password" type="password" value={newPassword} onChange={setNewPassword} placeholder="New password" />
<TextInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="Confirm new password"
/>
<Divider />
<Button
label="Update Password"
variant="primary"
icon={<MdLock size={16} />}
onClick={handleChangePassword}
isLoading={changePasswordMutation.isPending}
style={{ alignSelf: 'flex-start' }}
/>
</VStack>
</SectionCard>
</Grid>
</PageShell>
</>
);
};

View File

@@ -19,10 +19,39 @@ import { DT } from 'themes/dt/tokens';
const BRAND_ON_LIGHT = DT.brand; // #000000
const BRAND_ON_DARK = '#FFFFFF';
// Shared fallback stack. Every role appends this after its own family, so a
// blocked/slow Google Fonts request degrades to the platform UI font rather
// than to Times New Roman.
const SYSTEM_SANS = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
export const doormileTheme = defineTheme({
name: 'doormile',
extends: neutralTheme,
color: { neutralStyle: 'cool' },
// ---------------------------------------------------------------------
// Typography. This is the ONLY place font families and the type scale are
// decided; it drives --font-family-body/-heading/-code and the whole
// --text-heading-*/--text-body-* scale, so every Astryx component in the
// app moves together.
//
// Before this existed the app had three disagreeing sources and no scale at
// all: this file set no typography, config.js said 'Public Sans' (MUI), and
// Dispatch.css hardcoded 'Inter' + 'JetBrains Mono' in ~25 declarations.
//
// The families themselves are loaded by the <link> in public/index.html —
// Astryx explicitly does not load fonts, it only names them. Change one
// without the other and you get a silent fallback.
//
// base 14, not 16: this is a dense operator console and 14px is what the
// existing tables already assume. ratio 1.2 keeps the heading steps tight
// so an h1 doesn't tower over a data-heavy page.
// ---------------------------------------------------------------------
typography: {
scale: { base: 14, ratio: 1.2 },
body: { family: 'Inter', fallbacks: SYSTEM_SANS },
heading: { family: 'Sora', fallbacks: SYSTEM_SANS, weight: 'semibold' },
code: { family: 'ui-monospace', fallbacks: 'SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' }
},
tokens: {
'--color-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],
'--color-icon-accent': [BRAND_ON_LIGHT, BRAND_ON_DARK],

View File

@@ -30,19 +30,34 @@ export const softPopupStyle = {
// sticky-header or max-height affordance (MUI's TableContainer used to provide that),
// so pages wrap it in this. The brand-red scrollbar can't be expressed inline
// — ::-webkit-scrollbar-* are pseudo-elements — hence the one-time <style>.
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, children }) => (
// `flush` drops the grey header fill and leaves a single hairline under the
// header row — for tables that sit directly on the page with no surrounding
// card, where a fill plus a border plus a card outline is three lines doing
// one job.
export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, flush = false, children }) => (
<>
<style>{`
.dt-table-scroll { overflow: auto; }
.dt-table-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.dt-table-scroll::-webkit-scrollbar-track { background-color: ${DT.surfaceAlt}; }
.dt-table-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
.dt-table-scroll::-webkit-scrollbar-track { background-color: transparent; }
/* Was ${'`${DT.brand}55`'} — and DT.brand is BLACK, so this painted a
near-black 10px bar down the right edge of every scrollable table.
That is the "dark line" the table was being judged on: it reads as a
heavy border, not as a scrollbar. Now a light neutral that darkens
only on hover. */
.dt-table-scroll::-webkit-scrollbar-thumb {
background-color: ${DT.brand}55; border-radius: 8px;
background-color: rgba(15, 23, 42, 0.16); border-radius: 8px;
border: 2px solid transparent; background-clip: padding-box;
}
.dt-table-scroll::-webkit-scrollbar-thumb:hover { background-color: rgba(15, 23, 42, 0.34); }
.dt-table-scroll thead th { position: sticky; top: 0; z-index: 2; }
.dt-table-scroll:not(.is-flush) thead th { background-color: ${DT.surfaceAlt}; }
.dt-table-scroll.is-flush thead th {
background-color: ${DT.surface};
border-bottom: 1px solid ${DT.borderSubtle};
}
.dt-table-scroll::-webkit-scrollbar-thumb:hover { background-color: ${DT.brand}; }
.dt-table-scroll thead th { position: sticky; top: 0; z-index: 2; background-color: ${DT.surfaceAlt}; }
`}</style>
<div className="dt-table-scroll" style={{ maxHeight }}>
<div className={`dt-table-scroll${flush ? ' is-flush' : ''}`} style={{ maxHeight }}>
<div style={{ minWidth }}>{children}</div>
</div>
</>
@@ -51,6 +66,7 @@ export const TableScroll = ({ maxHeight = 'calc(100vh - 260px)', minWidth, child
TableScroll.propTypes = {
maxHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
minWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
flush: PropTypes.bool,
children: PropTypes.node
};