updates on the ui changes and table
This commit is contained in:
@@ -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
|
||||
|
||||
190
src/components/nearle_components/PageLayout.js
Normal file
190
src/components/nearle_components/PageLayout.js
Normal 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
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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't store an address against a customer yet — these fields aren't required and won'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't store an address against a customer yet — these fields aren't required and won'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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't store an address against a customer yet — these fields aren't required and won't be saved on submit.
|
||||
The backend doesn't store an address against a customer yet — these fields aren't required and won'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
@@ -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 {
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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 & 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 & 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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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={
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user