updates on the ui changes and changed into doormile
This commit is contained in:
132
src/pages/doormile/ViewProfile.jsx
Normal file
132
src/pages/doormile/ViewProfile.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, KeyRound, Mail, Shield, User } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, Field, Grid, Input, PageHeader, SectionHeader, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useProfile, useUpdatePassword } from '@/lib/doormileHooks';
|
||||
import { orDash } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* The signed-in operator's own account.
|
||||
*
|
||||
* `GET /admin/profile` has no documented schema, but the login response's
|
||||
* `user` shape — `{ id, name, email, role, tenantid }` — is confirmed, and this
|
||||
* endpoint almost certainly returns the same one. Address and locality fields
|
||||
* from the previous backend are deliberately not shown: there is nothing behind
|
||||
* them here, and a row of permanently blank fields reads as broken.
|
||||
*/
|
||||
|
||||
const ROLE_LABELS = { 1: 'Administrator', 3: 'Manager', 4: 'Executive' };
|
||||
|
||||
function InfoField({ icon: Icon, label, value }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-brand-tint text-brand">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-caption uppercase tracking-wide text-ink-3">{label}</p>
|
||||
<p className="truncate text-body-sm font-medium text-ink-1">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ViewProfile() {
|
||||
const { data: user, isLoading } = useProfile();
|
||||
const updatePassword = useUpdatePassword();
|
||||
|
||||
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const fullName = user?.name || user?.fullname || 'Operator';
|
||||
const roleLabel = ROLE_LABELS[user?.role] || ROLE_LABELS[user?.roleid] || user?.role || '—';
|
||||
const tenantLabel = user?.tenantid ? `Client #${user.tenantid}` : 'Doormile staff — every client';
|
||||
|
||||
const changePassword = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors = {};
|
||||
if (!form.current) nextErrors.current = 'Enter your current password';
|
||||
if (!form.next) nextErrors.next = 'Enter a new password';
|
||||
if (form.next && form.next !== form.confirm) nextErrors.confirm = 'The two entries do not match';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
updatePassword.mutate(
|
||||
{ current_password: form.current, new_password: form.next },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res?.success !== false) setForm({ current: '', next: '', confirm: '' });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader icon={User} title="My profile" subtitle="Account and security" />
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Avatar name={fullName} size="xl" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-heading text-title text-ink-1">{isLoading ? '—' : fullName}</h2>
|
||||
<p className="text-body-sm text-ink-3">{orDash(user?.email)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Grid cols={3} className="mt-6">
|
||||
<InfoField icon={Mail} label="Email" value={orDash(user?.email)} />
|
||||
<InfoField icon={Shield} label="Role" value={roleLabel} />
|
||||
<InfoField icon={Building2} label="Scope" value={tenantLabel} />
|
||||
</Grid>
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Change password"
|
||||
subtitle="You will stay signed in on this device after changing it."
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<form onSubmit={changePassword} id="security">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field label="Current password" required error={errors.current}>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={form.current}
|
||||
onChange={(e) => setForm({ ...form, current: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="New password" required error={errors.next}>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.next}
|
||||
onChange={(e) => setForm({ ...form, next: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Confirm new password" required error={errors.confirm}>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.confirm}
|
||||
onChange={(e) => setForm({ ...form, confirm: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end border-t border-border pt-5">
|
||||
<Button type="submit" loading={updatePassword.isPending}>
|
||||
<KeyRound className="mr-1.5 h-4 w-4" /> Update password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
324
src/pages/doormile/appUsers/AppUsers.jsx
Normal file
324
src/pages/doormile/appUsers/AppUsers.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pencil, Plus, ShieldCheck, Trash2, Users } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, ConfirmModal, DataTable, EmptyState, Field, Grid, IconButton, Input,
|
||||
KpiCard, Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useAppUsers, useCreateAppUser, useDeleteAppUser, useUpdateAppUser } from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* App users — the staff logins for this console.
|
||||
*
|
||||
* `GET /admin/users` returns `{ id, email, first_name, phone, role, status }`.
|
||||
* `role` is free text there ("admin" and "rep" both seen live); the numeric
|
||||
* role ids in the auth documentation describe the JWT encoding used at login,
|
||||
* not this field, so the picker offers the observed strings rather than ids.
|
||||
*/
|
||||
|
||||
const ROLES = ['admin', 'manager', 'executive', 'rep'];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const titleCase = (value) => (value ? String(value).charAt(0).toUpperCase() + String(value).slice(1) : '');
|
||||
|
||||
const EMPTY_FORM = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
|
||||
|
||||
export default function AppUsers() {
|
||||
const { data: users = [], isLoading, isFetching } = useAppUsers();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const createUser = useCreateAppUser();
|
||||
const updateUser = useUpdateAppUser();
|
||||
const deleteUser = useDeleteAppUser();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = users.length;
|
||||
return {
|
||||
total,
|
||||
admins: users.filter((user) => String(user.role || '').toLowerCase() === 'admin').length,
|
||||
active: users.filter((user) => String(user.status || '').toLowerCase() === 'active').length,
|
||||
};
|
||||
}, [users]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = users;
|
||||
if (activeFilter === 'admin') list = list.filter((u) => String(u.role || '').toLowerCase() === 'admin');
|
||||
else if (activeFilter === 'active') list = list.filter((u) => String(u.status || '').toLowerCase() === 'active');
|
||||
return list.filter((u) => matchesQuery(u, ['first_name', 'email', 'phone', 'role'], debouncedSearch));
|
||||
}, [users, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
id: row.id,
|
||||
first_name: row.first_name || '',
|
||||
email: row.email || '',
|
||||
phone: row.phone || '',
|
||||
/* Never prefilled, and left blank means "leave the password alone" — see
|
||||
the save path, which strips the key rather than sending an empty one. */
|
||||
password: '',
|
||||
role: row.role || 'admin',
|
||||
status: row.status || 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.first_name.trim()) nextErrors.first_name = 'Enter a name';
|
||||
if (!form.email.trim()) nextErrors.email = 'Enter an email address';
|
||||
if (!form.id && !form.password) nextErrors.password = 'Set a password for the new account';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const { id, ...data } = form;
|
||||
/* An empty password on an edit is "unchanged", not "blank it" — sending the
|
||||
empty string would set the account's password to nothing. */
|
||||
if (id && !data.password) delete data.password;
|
||||
|
||||
const mutation = id ? updateUser : createUser;
|
||||
const variables = id ? { id, data } : data;
|
||||
mutation.mutate(variables, { onSuccess: (res) => res?.success !== false && setDialogOpen(false) });
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'first_name',
|
||||
header: 'User',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={row.first_name || row.email} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.first_name)}</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(row.email)}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', header: 'Phone', sortable: true, accessor: (row) => orDash(row.phone), hideBelow: 'md' },
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
sortable: true,
|
||||
accessor: (row) => titleCase(row.role),
|
||||
cell: (row) => <StatusBadge status={row.role} label={titleCase(row.role) || '—'} tone="info" size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit user" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton label="Remove user" icon={Trash2} variant="ghost" size="sm" onClick={() => setPendingDelete(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title="App users"
|
||||
subtitle="Staff logins for the Doormile Express console"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New user
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={3}>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={stats.total}
|
||||
label="Total users"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ShieldCheck}
|
||||
value={stats.admins}
|
||||
label={`Administrators · ${percentOf(stats.admins, stats.total)}%`}
|
||||
tone="navy"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('admin')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={stats.active}
|
||||
label={`Active · ${percentOf(stats.active, stats.total)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('active')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by name, email, phone or role"
|
||||
count={{ shown: rows.length, total: users.length, noun: 'users' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.id}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'first_name', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No console users yet"
|
||||
description="Create the first staff login so someone other than you can work the board."
|
||||
action={{ label: 'New user', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={form.id ? 'Edit user' : 'New user'}
|
||||
icon={Users}
|
||||
size="lg"
|
||||
busy={createUser.isPending || updateUser.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createUser.isPending || updateUser.isPending}>
|
||||
{form.id ? 'Save changes' : 'Create user'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Name" required error={errors.first_name}>
|
||||
<Input value={form.first_name} onChange={(e) => set('first_name')(e.target.value)} placeholder="Priya R" />
|
||||
</Field>
|
||||
|
||||
<Field label="Email" required error={errors.email}>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set('email')(e.target.value)}
|
||||
placeholder="priya@doormile.com"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Phone">
|
||||
<Input value={form.phone} onChange={(e) => set('phone')(e.target.value)} placeholder="9876543210" />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Password"
|
||||
required={!form.id}
|
||||
error={errors.password}
|
||||
hint={form.id ? 'Leave blank to keep the current password' : undefined}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(e) => set('password')(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Role">
|
||||
<Select value={form.role} onValueChange={set('role')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{titleCase(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Remove this user?"
|
||||
description={`${pendingDelete?.first_name || pendingDelete?.email || 'This account'} will lose access to the console immediately.`}
|
||||
confirmLabel="Remove user"
|
||||
busy={deleteUser.isPending}
|
||||
onConfirm={() => {
|
||||
deleteUser.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
156
src/pages/doormile/clients/CreateClient.jsx
Normal file
156
src/pages/doormile/clients/CreateClient.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Building2 } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack, Surface, Switch,
|
||||
} from '@/components/ds';
|
||||
import { useCreateTenant } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Create a client (tenant).
|
||||
*
|
||||
* `status` is stored exactly as sent — a lower-cased value is accepted but
|
||||
* would leave this record inconsistent with every other tenant, so the picker
|
||||
* only offers title case.
|
||||
*/
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
export default function CreateClient() {
|
||||
const navigate = useNavigate();
|
||||
const createTenant = useCreateTenant();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
tenantname: '',
|
||||
primaryemail: '',
|
||||
primarycontact: '',
|
||||
status: 'Active',
|
||||
requiredeliveryotp: false,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors = {};
|
||||
if (!form.tenantname.trim()) nextErrors.tenantname = 'Enter the business name';
|
||||
if (!/^\d{10}$/.test(form.primarycontact)) nextErrors.primarycontact = 'Enter a 10-digit contact number';
|
||||
if (!form.primaryemail.trim()) nextErrors.primaryemail = 'Enter the primary email';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createTenant.mutate(
|
||||
{
|
||||
tenantname: form.tenantname,
|
||||
primaryemail: form.primaryemail,
|
||||
primarycontact: form.primarycontact,
|
||||
status: form.status,
|
||||
/* Delivery OTP is a pointer server-side and off by default, so the key
|
||||
is sent only when the operator actually turns it on — sending false
|
||||
and omitting it are not the same request. */
|
||||
...(form.requiredeliveryotp ? { requiredeliveryotp: true } : {}),
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/tenants') }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Building2}
|
||||
title="New client"
|
||||
subtitle="Onboard a tenant company onto the network"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/tenants">Clients</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>New client</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/tenants')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<Surface radius="xl" padding="lg">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Client / business name" required error={errors.tenantname} className="sm:col-span-2">
|
||||
<Input
|
||||
value={form.tenantname}
|
||||
onChange={(e) => set('tenantname')(e.target.value)}
|
||||
placeholder="Peelamedu Provisions"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Primary contact" required error={errors.primarycontact}>
|
||||
<Input
|
||||
value={form.primarycontact}
|
||||
onChange={(e) => set('primarycontact')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="9876543210"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Primary email" required error={errors.primaryemail}>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.primaryemail}
|
||||
onChange={(e) => set('primaryemail')(e.target.value)}
|
||||
placeholder="ops@client.com"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Require delivery OTP"
|
||||
inline
|
||||
hint="The rider must collect a one-time code at the door"
|
||||
>
|
||||
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/tenants')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createTenant.isPending}>
|
||||
Create client
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
180
src/pages/doormile/clients/CreateCustomer.jsx
Normal file
180
src/pages/doormile/clients/CreateCustomer.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Users } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, SectionHeader, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useCreateCustomer } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Create a customer.
|
||||
*
|
||||
* Writes to `/admin/customers` — the same store the Customers page reads — so a
|
||||
* customer added here shows up there immediately rather than landing in the
|
||||
* tenant-customers resource, which the console lists somewhere else entirely.
|
||||
*/
|
||||
|
||||
const EMPTY_FORM = {
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
doorno: '',
|
||||
address: '',
|
||||
landmark: '',
|
||||
suburb: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
};
|
||||
|
||||
/** Drops blank keys — the API treats an empty string and an absent field
|
||||
differently on several of these, and an empty one is never what was meant. */
|
||||
const clean = (object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(object).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
);
|
||||
|
||||
export default function CreateCustomer() {
|
||||
const navigate = useNavigate();
|
||||
const createCustomer = useCreateCustomer();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors = {};
|
||||
if (!form.firstname.trim()) nextErrors.firstname = 'Enter a name';
|
||||
if (!/^\d{10}$/.test(form.phone)) nextErrors.phone = 'Enter a 10-digit mobile number';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createCustomer.mutate(
|
||||
clean({
|
||||
...form,
|
||||
latitude: form.latitude ? Number(form.latitude) : undefined,
|
||||
longitude: form.longitude ? Number(form.longitude) : undefined,
|
||||
}),
|
||||
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/customers') }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title="New customer"
|
||||
subtitle="A delivery address and the person to hand the parcel to"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/customers">Customers</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>New customer</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/customers')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<Stack space="default">
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Who" className="mb-4" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label="First name" required error={errors.firstname}>
|
||||
<Input value={form.firstname} onChange={(e) => set('firstname')(e.target.value)} placeholder="Anitha" />
|
||||
</Field>
|
||||
<Field label="Last name">
|
||||
<Input value={form.lastname} onChange={(e) => set('lastname')(e.target.value)} placeholder="Raman" />
|
||||
</Field>
|
||||
<Field label="Mobile number" required error={errors.phone}>
|
||||
<Input
|
||||
value={form.phone}
|
||||
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="9876543210"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set('email')(e.target.value)}
|
||||
placeholder="anitha@example.com"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Where"
|
||||
subtitle="Coordinates are optional — a rider can still be routed on the address alone."
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Door number">
|
||||
<Input value={form.doorno} onChange={(e) => set('doorno')(e.target.value)} placeholder="12/4" />
|
||||
</Field>
|
||||
<Field label="Address" className="lg:col-span-2">
|
||||
<Input
|
||||
value={form.address}
|
||||
onChange={(e) => set('address')(e.target.value)}
|
||||
placeholder="Street, area"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Landmark">
|
||||
<Input value={form.landmark} onChange={(e) => set('landmark')(e.target.value)} placeholder="Near the temple" />
|
||||
</Field>
|
||||
<Field label="Suburb">
|
||||
<Input value={form.suburb} onChange={(e) => set('suburb')(e.target.value)} placeholder="Peelamedu" />
|
||||
</Field>
|
||||
<Field label="City">
|
||||
<Input value={form.city} onChange={(e) => set('city')(e.target.value)} placeholder="Coimbatore" />
|
||||
</Field>
|
||||
<Field label="State">
|
||||
<Input value={form.state} onChange={(e) => set('state')(e.target.value)} placeholder="Tamil Nadu" />
|
||||
</Field>
|
||||
<Field label="Pincode">
|
||||
<Input value={form.postcode} onChange={(e) => set('postcode')(e.target.value)} placeholder="641004" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Latitude">
|
||||
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
|
||||
</Field>
|
||||
<Field label="Longitude">
|
||||
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/customers')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createCustomer.isPending}>
|
||||
Create customer
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
582
src/pages/doormile/clients/Tenants.jsx
Normal file
582
src/pages/doormile/clients/Tenants.jsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Building2, CheckCircle2, CircleSlash, IndianRupee, Mail, MapPin, Pencil, Phone,
|
||||
Plus, Timer, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Input, KpiCard,
|
||||
Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Stack, StatusBadge, Surface, Switch, Tabs,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useCreateTenantLocation, usePricing, useTenantCustomers, useTenantLocations,
|
||||
useTenants, useUpdateTenant, useUpdateTenantLocation,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { currency, matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Clients — the tenant companies whose deliveries this network carries.
|
||||
*
|
||||
* A tenant owns three things the console has to reach: its branches (the sites
|
||||
* parcels are collected from), its own end customers, and its pricing rules.
|
||||
* Each opens from the row rather than living on a separate page, because all
|
||||
* three are read in the context of "this client" and never on their own.
|
||||
*/
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const statusOf = (tenant) => String(tenant?.status || '').toLowerCase();
|
||||
|
||||
const EMPTY_LOCATION = {
|
||||
locationid: null,
|
||||
locationname: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
pincode: '',
|
||||
isprimary: false,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
/** Branches for one tenant, with add and edit in the same panel. */
|
||||
function BranchesDrawer({ tenant, open, onClose }) {
|
||||
const { data: locations = [], isLoading } = useTenantLocations(open ? tenant?.tenantid : null);
|
||||
const createLocation = useCreateTenantLocation();
|
||||
const updateLocation = useUpdateTenantLocation();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_LOCATION);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openEdit = (location) => {
|
||||
setForm({
|
||||
locationid: location.locationid,
|
||||
locationname: location.locationname || '',
|
||||
address: location.address || '',
|
||||
city: location.city || '',
|
||||
state: location.state || '',
|
||||
pincode: location.pincode || '',
|
||||
isprimary: Boolean(location.isprimary),
|
||||
status: location.status || 'active',
|
||||
});
|
||||
setErrors({});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.locationname.trim()) nextErrors.locationname = 'Name the branch';
|
||||
if (!form.address.trim()) nextErrors.address = 'Enter the address';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const { locationid, ...data } = form;
|
||||
const done = () => {
|
||||
setForm(EMPTY_LOCATION);
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
if (locationid) updateLocation.mutate({ locationId: locationid, data }, { onSuccess: done });
|
||||
else createLocation.mutate({ tenantId: tenant.tenantid, data }, { onSuccess: done });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Branches · ${tenant?.tenantname || ''}`}
|
||||
description="The sites this client's parcels are collected from."
|
||||
size="lg"
|
||||
footer={
|
||||
formOpen ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setFormOpen(false);
|
||||
setForm(EMPTY_LOCATION);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createLocation.isPending || updateLocation.isPending}>
|
||||
{form.locationid ? 'Save changes' : 'Add branch'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setForm(EMPTY_LOCATION);
|
||||
setErrors({});
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Add branch
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
{isLoading ? (
|
||||
<p className="text-body-sm text-ink-3">Loading branches…</p>
|
||||
) : locations.length === 0 && !formOpen ? (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
size="sm"
|
||||
title="No branches yet"
|
||||
description="Add the first site parcels are collected from."
|
||||
/>
|
||||
) : (
|
||||
locations.map((location) => (
|
||||
<Surface key={location.locationid} variant="subtle" padding="sm" radius="lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">
|
||||
{location.locationname}
|
||||
{location.isprimary ? <span className="ml-1.5 text-caption text-brand">· Primary</span> : null}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{[location.address, location.city, location.state, location.pincode].filter(Boolean).join(', ') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Edit branch"
|
||||
icon={Pencil}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(location)}
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
))
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<Surface variant="outline" padding="sm" radius="lg">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Branch name" required error={errors.locationname}>
|
||||
<Input value={form.locationname} onChange={(e) => set('locationname')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Pincode">
|
||||
<Input value={form.pincode} onChange={(e) => set('pincode')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Address" required error={errors.address} className="sm:col-span-2">
|
||||
<Input value={form.address} onChange={(e) => set('address')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="City">
|
||||
<Input value={form.city} onChange={(e) => set('city')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="State">
|
||||
<Input value={form.state} onChange={(e) => set('state')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Primary branch" inline className="sm:col-span-2">
|
||||
<Switch checked={form.isprimary} onCheckedChange={set('isprimary')} />
|
||||
</Field>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant's own end customers.
|
||||
*
|
||||
* `GET /admin/tenantcustomers` documents no tenant filter — unlike locations,
|
||||
* which are nested under the tenant — so the list is narrowed client-side.
|
||||
*/
|
||||
function CustomersDrawer({ tenant, open, onClose }) {
|
||||
const { data: allCustomers = [], isLoading } = useTenantCustomers({ enabled: open });
|
||||
|
||||
const customers = useMemo(
|
||||
() => (allCustomers || []).filter((row) => String(row.tenantid) === String(tenant?.tenantid)),
|
||||
[allCustomers, tenant]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Customers · ${tenant?.tenantname || ''}`}
|
||||
description="Filtered from the full customer list — this resource takes no tenant parameter."
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-body-sm text-ink-3">Loading customers…</p>
|
||||
) : customers.length === 0 ? (
|
||||
<EmptyState icon={Users} size="sm" title="No customers on this client" />
|
||||
) : (
|
||||
<Stack space="sm">
|
||||
{customers.map((customer) => (
|
||||
<Surface
|
||||
key={customer.id ?? customer.tenantcustomerid ?? customer.customerid}
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
radius="lg"
|
||||
>
|
||||
<p className="text-body-sm font-semibold text-ink-1">
|
||||
{[customer.firstname, customer.lastname].filter(Boolean).join(' ') || customer.name || '—'}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{[customer.phone, customer.email].filter(Boolean).join(' · ') || '—'}
|
||||
</p>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pricing rules that apply to one tenant. */
|
||||
function PricingDrawer({ tenant, open, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allPricing = [], isLoading } = usePricing({ enabled: open });
|
||||
|
||||
const rules = useMemo(
|
||||
() => (allPricing || []).filter((row) => String(row.tenantid) === String(tenant?.tenantid)),
|
||||
[allPricing, tenant]
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ key: 'vehicletype', header: 'Vehicle', accessor: (row) => orDash(row.vehicletype) },
|
||||
{ key: 'baseprice', header: 'Base', accessor: (row) => currency(row.baseprice), align: 'right' },
|
||||
{ key: 'priceperkm', header: 'Per km', accessor: (row) => currency(row.priceperkm), align: 'right' },
|
||||
{ key: 'priceperkg', header: 'Per kg', accessor: (row) => currency(row.priceperkg), align: 'right' },
|
||||
{ key: 'handlingcharges', header: 'Handling', accessor: (row) => currency(row.handlingcharges), align: 'right' },
|
||||
{ key: 'status', header: 'Status', cell: (row) => <StatusBadge status={row.status} size="sm" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Pricing · ${tenant?.tenantname || ''}`}
|
||||
description="The rate card quotes for this client are calculated from."
|
||||
size="xl"
|
||||
footer={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/pricing')}>
|
||||
Open pricing
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rules}
|
||||
getRowId={(row, index) => row.pricingid ?? index}
|
||||
loading={isLoading}
|
||||
emptyState={<EmptyState icon={IndianRupee} size="sm" title="No pricing rules for this client" />}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Tenants() {
|
||||
const navigate = useNavigate();
|
||||
const { data: tenants = [], isLoading, isFetching } = useTenants();
|
||||
const updateTenant = useUpdateTenant();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [tab, setTab] = useState('active');
|
||||
|
||||
const [editRow, setEditRow] = useState(null);
|
||||
const [form, setForm] = useState({});
|
||||
const [branchesTenant, setBranchesTenant] = useState(null);
|
||||
const [customersTenant, setCustomersTenant] = useState(null);
|
||||
const [pricingTenant, setPricingTenant] = useState(null);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = tenants || [];
|
||||
return {
|
||||
total: list.length,
|
||||
active: list.filter((row) => statusOf(row) === 'active').length,
|
||||
pending: list.filter((row) => statusOf(row) === 'pending').length,
|
||||
inactive: list.filter((row) => statusOf(row) === 'inactive').length,
|
||||
};
|
||||
}, [tenants]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(tenants || [])
|
||||
.filter((row) => statusOf(row) === tab)
|
||||
.filter((row) => matchesQuery(row, ['tenantname', 'primaryemail', 'primarycontact'], debouncedSearch)),
|
||||
[tenants, tab, debouncedSearch]
|
||||
);
|
||||
|
||||
const openEdit = (row) => {
|
||||
setEditRow(row);
|
||||
setForm({
|
||||
tenantname: row.tenantname || '',
|
||||
primarycontact: row.primarycontact || '',
|
||||
primaryemail: row.primaryemail || '',
|
||||
address: row.address || '',
|
||||
status: row.status || 'active',
|
||||
requiredeliveryotp: Boolean(row.requiredeliveryotp),
|
||||
});
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
updateTenant.mutate(
|
||||
{ id: editRow.tenantid, data: form },
|
||||
{ onSuccess: (res) => res?.success !== false && setEditRow(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'tenantname',
|
||||
header: 'Client',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-brand-tint text-brand">
|
||||
<Building2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.tenantname)}</p>
|
||||
<p className="text-caption text-ink-3">ID #{row.tenantid}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'primarycontact',
|
||||
header: 'Contact',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-body-sm text-ink-1">
|
||||
<Phone className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
{orDash(row.primarycontact)}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-caption text-ink-3">
|
||||
<Mail className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
{orDash(row.primaryemail)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'requiredeliveryotp',
|
||||
header: 'Delivery OTP',
|
||||
sortValue: (row) => (row.requiredeliveryotp ? 1 : 0),
|
||||
accessor: (row) => (row.requiredeliveryotp ? 'Required' : 'Not required'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Branches" icon={MapPin} variant="ghost" size="sm" onClick={() => setBranchesTenant(row)} />
|
||||
<IconButton label="Customers" icon={Users} variant="ghost" size="sm" onClick={() => setCustomersTenant(row)} />
|
||||
<IconButton
|
||||
label="Pricing"
|
||||
icon={IndianRupee}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPricingTenant(row)}
|
||||
/>
|
||||
<IconButton label="Edit client" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Building2}
|
||||
title="Clients"
|
||||
subtitle="The tenant companies whose deliveries this network carries"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/doormile/clients/create')}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New client
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Building2} value={stats.total} label="Total clients" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={stats.active}
|
||||
label={`Active · ${percentOf(stats.active, stats.total)}%`}
|
||||
tone="success"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => setTab('active')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Timer}
|
||||
value={stats.pending}
|
||||
label="Pending approval"
|
||||
tone="warning"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => setTab('pending')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CircleSlash}
|
||||
value={stats.inactive}
|
||||
label="Inactive"
|
||||
tone="neutral"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => setTab('inactive')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<Tabs
|
||||
tabs={STATUS_TABS.map((entry) => ({ ...entry, count: stats[entry.value] }))}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by client name, email or phone"
|
||||
className="w-72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length === (stats[tab] ?? 0)
|
||||
? `${stats[tab] ?? 0} clients`
|
||||
: `${rows.length} of ${stats[tab] ?? 0} clients`}
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.tenantid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'tenantname', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
debouncedSearch ? undefined : (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title={`No ${tab} clients`}
|
||||
description="Clients appear here once they are onboarded and approved."
|
||||
action={{ label: 'New client', icon: Plus, onClick: () => navigate('/doormile/clients/create') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editRow)}
|
||||
onOpenChange={(open) => !open && setEditRow(null)}
|
||||
title="Edit client"
|
||||
description={editRow ? `ID #${editRow.tenantid}` : undefined}
|
||||
icon={Building2}
|
||||
size="lg"
|
||||
busy={updateTenant.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setEditRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={updateTenant.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Client name" className="sm:col-span-2">
|
||||
<Input
|
||||
value={form.tenantname || ''}
|
||||
onChange={(e) => setForm({ ...form, tenantname: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Primary contact">
|
||||
<Input
|
||||
value={form.primarycontact || ''}
|
||||
onChange={(e) => setForm({ ...form, primarycontact: e.target.value.slice(0, 10) })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Primary email">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.primaryemail || ''}
|
||||
onChange={(e) => setForm({ ...form, primaryemail: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Address" className="sm:col-span-2">
|
||||
<Input value={form.address || ''} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status || 'active'} onValueChange={(value) => setForm({ ...form, status: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_TABS.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Require delivery OTP"
|
||||
inline
|
||||
hint="The rider must collect a one-time code at the door"
|
||||
>
|
||||
<Switch
|
||||
checked={Boolean(form.requiredeliveryotp)}
|
||||
onCheckedChange={(value) => setForm({ ...form, requiredeliveryotp: value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<BranchesDrawer tenant={branchesTenant} open={Boolean(branchesTenant)} onClose={() => setBranchesTenant(null)} />
|
||||
<CustomersDrawer
|
||||
tenant={customersTenant}
|
||||
open={Boolean(customersTenant)}
|
||||
onClose={() => setCustomersTenant(null)}
|
||||
/>
|
||||
<PricingDrawer tenant={pricingTenant} open={Boolean(pricingTenant)} onClose={() => setPricingTenant(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
324
src/pages/doormile/competitiveIntel/CompetitiveIntel.jsx
Normal file
324
src/pages/doormile/competitiveIntel/CompetitiveIntel.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, Pencil, Plus, Radar, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button, ConfirmModal, DataTable, EmptyState, Field, IconButton, Input, Modal,
|
||||
PageHeader, Pagination, Stack, Surface, Tabs,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
useCarrierPricing, useCompetitorBranches, useCreateCarrierPricing,
|
||||
useCreateCompetitorBranch, useDeleteCarrierPricing, useDeleteCompetitorBranch,
|
||||
useUpdateCarrierPricing, useUpdateCompetitorBranch,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { orDash } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Competitive intelligence — what the other carriers in each area charge.
|
||||
*
|
||||
* Two resources behind one page: competitor branches, and the carrier rate
|
||||
* cards collected against them. Both paginate on `page`/`limit` rather than the
|
||||
* `pageno`/`pagesize` convention the rest of the API uses, and both return far
|
||||
* more rows than one page holds — so the pager here is server-side, not a slice
|
||||
* of an already-complete list.
|
||||
*
|
||||
* Rate fields are free text on purpose. Real values include "600", "25-35",
|
||||
* "Not Answered" and "." — casting them to numbers would discard most of the
|
||||
* data that was actually collected.
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const TABS = [
|
||||
{ value: 'branches', label: 'Competitor branches' },
|
||||
{ value: 'pricing', label: 'Carrier pricing' },
|
||||
];
|
||||
|
||||
const EMPTY_BRANCH = {
|
||||
id: null,
|
||||
company: '',
|
||||
area: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
pincodes: '',
|
||||
rate_per_kg: '',
|
||||
offers_pickup: '',
|
||||
offers_drop: '',
|
||||
time_in_days: '',
|
||||
packing_charge: '',
|
||||
};
|
||||
|
||||
const EMPTY_CARRIER = {
|
||||
id: null,
|
||||
company: '',
|
||||
weight_slab: '',
|
||||
service_type: '',
|
||||
zone: '',
|
||||
rate: '',
|
||||
delivery_time: '',
|
||||
};
|
||||
|
||||
export default function CompetitiveIntel() {
|
||||
const [tab, setTab] = useState('branches');
|
||||
const [page, setPage] = useState(1);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [branchForm, setBranchForm] = useState(EMPTY_BRANCH);
|
||||
const [carrierForm, setCarrierForm] = useState(EMPTY_CARRIER);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const branchesQuery = useCompetitorBranches(page, PAGE_SIZE, { enabled: tab === 'branches' });
|
||||
const carrierQuery = useCarrierPricing(page, PAGE_SIZE, { enabled: tab === 'pricing' });
|
||||
|
||||
const createBranch = useCreateCompetitorBranch();
|
||||
const updateBranch = useUpdateCompetitorBranch();
|
||||
const deleteBranch = useDeleteCompetitorBranch();
|
||||
const createCarrier = useCreateCarrierPricing();
|
||||
const updateCarrier = useUpdateCarrierPricing();
|
||||
const deleteCarrier = useDeleteCarrierPricing();
|
||||
|
||||
const onBranches = tab === 'branches';
|
||||
const response = onBranches ? branchesQuery.data : carrierQuery.data;
|
||||
const rows = response?.data || [];
|
||||
const total = Number(response?.total) || 0;
|
||||
const isLoading = onBranches ? branchesQuery.isLoading : carrierQuery.isLoading;
|
||||
const isFetching = onBranches ? branchesQuery.isFetching : carrierQuery.isFetching;
|
||||
|
||||
const changeTab = (next) => {
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBranch = (key) => (value) => setBranchForm((current) => ({ ...current, [key]: value }));
|
||||
const setCarrier = (key) => (value) => setCarrierForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setBranchForm(EMPTY_BRANCH);
|
||||
setCarrierForm(EMPTY_CARRIER);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
if (onBranches) setBranchForm({ ...EMPTY_BRANCH, ...row });
|
||||
else setCarrierForm({ ...EMPTY_CARRIER, ...row });
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const close = { onSuccess: (res) => res?.success !== false && setDialogOpen(false) };
|
||||
|
||||
if (onBranches) {
|
||||
if (!branchForm.company.trim()) {
|
||||
setErrors({ company: 'Enter the company name' });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const { id, ...data } = branchForm;
|
||||
if (id) updateBranch.mutate({ id, data }, close);
|
||||
else createBranch.mutate(data, close);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextErrors = {};
|
||||
if (!carrierForm.company.trim()) nextErrors.company = 'Enter the company name';
|
||||
if (!carrierForm.weight_slab.trim()) nextErrors.weight_slab = 'Enter the weight slab';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const { id, ...data } = carrierForm;
|
||||
if (id) updateCarrier.mutate({ id, data }, close);
|
||||
else createCarrier.mutate(data, close);
|
||||
};
|
||||
|
||||
const rowActions = (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton label="Delete" icon={Trash2} variant="ghost" size="sm" onClick={() => setPendingDelete(row)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const branchColumns = [
|
||||
{
|
||||
key: 'company',
|
||||
header: 'Company',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.company)}</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(row.area)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', header: 'Phone', accessor: (row) => orDash(row.phone), hideBelow: 'md' },
|
||||
{
|
||||
key: 'address',
|
||||
header: 'Address',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
|
||||
{orDash(row.address)}
|
||||
</p>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{ key: 'pincodes', header: 'Pincodes', accessor: (row) => orDash(row.pincodes), hideBelow: 'lg' },
|
||||
{ key: 'rate_per_kg', header: 'Rate / kg', sortable: true, accessor: (row) => orDash(row.rate_per_kg), align: 'right' },
|
||||
{ key: 'actions', header: 'Actions', align: 'right', cell: rowActions },
|
||||
];
|
||||
|
||||
const carrierColumns = [
|
||||
{ key: 'company', header: 'Company', sortable: true, accessor: (row) => orDash(row.company) },
|
||||
{ key: 'weight_slab', header: 'Weight slab', sortable: true, accessor: (row) => orDash(row.weight_slab) },
|
||||
{ key: 'service_type', header: 'Service', accessor: (row) => orDash(row.service_type), hideBelow: 'md' },
|
||||
{ key: 'zone', header: 'Zone', sortable: true, accessor: (row) => orDash(row.zone) },
|
||||
{ key: 'rate', header: 'Rate', accessor: (row) => orDash(row.rate), align: 'right' },
|
||||
{ key: 'delivery_time', header: 'Delivery time', accessor: (row) => orDash(row.delivery_time), hideBelow: 'lg' },
|
||||
{ key: 'actions', header: 'Actions', align: 'right', cell: rowActions },
|
||||
];
|
||||
|
||||
const busy =
|
||||
createBranch.isPending || updateBranch.isPending || createCarrier.isPending || updateCarrier.isPending;
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Radar}
|
||||
title="Competitive intel"
|
||||
subtitle="What the other carriers in each area charge"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> {onBranches ? 'New branch' : 'New rate'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
<Tabs tabs={TABS} value={tab} onChange={changeTab} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-caption text-ink-3">
|
||||
{total ? `${total} records · page ${page} of ${Math.max(1, Math.ceil(total / PAGE_SIZE))}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={onBranches ? branchColumns : carrierColumns}
|
||||
rows={rows}
|
||||
getRowId={(row, index) => row.id ?? index}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={onBranches ? Building2 : Radar}
|
||||
title={onBranches ? 'No competitor branches recorded' : 'No carrier rates recorded'}
|
||||
description="Add what you know about the competition in each area."
|
||||
action={{ label: onBranches ? 'New branch' : 'New rate', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Server-side: each page is its own request, so this is not a slice of
|
||||
an already-complete list. */}
|
||||
{total > PAGE_SIZE && (
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={Math.max(1, Math.ceil(total / PAGE_SIZE))}
|
||||
pageSize={PAGE_SIZE}
|
||||
totalItems={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Surface>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={onBranches ? 'Competitor branch' : 'Carrier rate'}
|
||||
description="Rates are free text — record them exactly as they were quoted."
|
||||
icon={Radar}
|
||||
size="lg"
|
||||
busy={busy}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={busy}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{onBranches ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Company" required error={errors.company}>
|
||||
<Input value={branchForm.company} onChange={(e) => setBranch('company')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Area">
|
||||
<Input value={branchForm.area} onChange={(e) => setBranch('area')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone">
|
||||
<Input value={branchForm.phone} onChange={(e) => setBranch('phone')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Pincodes" hint="Comma-separated">
|
||||
<Input value={branchForm.pincodes} onChange={(e) => setBranch('pincodes')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Address" className="sm:col-span-2">
|
||||
<Input value={branchForm.address} onChange={(e) => setBranch('address')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rate per kg" hint='As quoted — "600", "25-35" and "Not answered" are all valid'>
|
||||
<Input value={branchForm.rate_per_kg} onChange={(e) => setBranch('rate_per_kg')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Packing charge">
|
||||
<Input value={branchForm.packing_charge} onChange={(e) => setBranch('packing_charge')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Offers pickup">
|
||||
<Input value={branchForm.offers_pickup} onChange={(e) => setBranch('offers_pickup')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Offers drop">
|
||||
<Input value={branchForm.offers_drop} onChange={(e) => setBranch('offers_drop')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Time in days">
|
||||
<Input value={branchForm.time_in_days} onChange={(e) => setBranch('time_in_days')(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Company" required error={errors.company}>
|
||||
<Input value={carrierForm.company} onChange={(e) => setCarrier('company')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Weight slab" required error={errors.weight_slab}>
|
||||
<Input value={carrierForm.weight_slab} onChange={(e) => setCarrier('weight_slab')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Service type">
|
||||
<Input value={carrierForm.service_type} onChange={(e) => setCarrier('service_type')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Zone">
|
||||
<Input value={carrierForm.zone} onChange={(e) => setCarrier('zone')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Rate">
|
||||
<Input value={carrierForm.rate} onChange={(e) => setCarrier('rate')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Delivery time">
|
||||
<Input value={carrierForm.delivery_time} onChange={(e) => setCarrier('delivery_time')(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete this record?"
|
||||
description={`${pendingDelete?.company || 'This record'} will be removed from the competitive register.`}
|
||||
confirmLabel="Delete"
|
||||
busy={deleteBranch.isPending || deleteCarrier.isPending}
|
||||
onConfirm={() => {
|
||||
const mutation = onBranches ? deleteBranch : deleteCarrier;
|
||||
mutation.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
270
src/pages/doormile/customers/Customers.jsx
Normal file
270
src/pages/doormile/customers/Customers.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Package, Pencil, Phone, Plus, Users } from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard,
|
||||
Modal, PageHeader, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useTenantCustomers, useUpdateTenantCustomer } from '@/lib/doormileHooks';
|
||||
import { matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Customers — the end customers a client's bookings are delivered to.
|
||||
*
|
||||
* The identifier and name fields differ between the two customer resources
|
||||
* this list can be fed from, so both are read through the accessors below
|
||||
* rather than assumed: a row that arrives as `firstname`/`lastname` and one
|
||||
* that arrives as `name` have to render identically.
|
||||
*/
|
||||
|
||||
const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id;
|
||||
const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—';
|
||||
const custPhone = (row) => row.phone || row.contactno || '';
|
||||
const custEmail = (row) => row.email || '';
|
||||
/** Tenant customers carry no booking count; 0 is honest rather than blank. */
|
||||
const custBookings = (row) => row.totalbookings ?? 0;
|
||||
|
||||
export default function Customers() {
|
||||
const navigate = useNavigate();
|
||||
const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
|
||||
const updateCustomer = useUpdateTenantCustomer();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [editRow, setEditRow] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', phone: '', email: '' });
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = customers.length;
|
||||
return {
|
||||
total,
|
||||
withEmail: customers.filter((row) => Boolean(row.email)).length,
|
||||
withPhone: customers.filter((row) => Boolean(row.phone)).length,
|
||||
totalBookings: customers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0),
|
||||
};
|
||||
}, [customers]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = customers;
|
||||
if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email));
|
||||
else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone));
|
||||
return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail], debouncedSearch));
|
||||
}, [customers, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
|
||||
const openEdit = (row) => {
|
||||
setEditRow(row);
|
||||
setForm({ name: custName(row) === '—' ? '' : custName(row), phone: custPhone(row), email: custEmail(row) });
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
/* The record stores firstname/lastname but the dialog edits one name field.
|
||||
Splitting on the first space keeps the surname through a round trip
|
||||
instead of blanking it. */
|
||||
const [firstname, ...rest] = String(form.name || '').trim().split(/\s+/);
|
||||
updateCustomer.mutate(
|
||||
{
|
||||
id: custId(editRow),
|
||||
data: {
|
||||
firstname: firstname || '',
|
||||
lastname: rest.join(' '),
|
||||
phone: form.phone || '',
|
||||
email: form.email || '',
|
||||
},
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setEditRow(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'Customer',
|
||||
sortable: true,
|
||||
accessor: custName,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={custName(row)} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{custName(row)}</p>
|
||||
<p className="text-caption text-ink-3">ID #{custId(row) ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
header: 'Contact',
|
||||
sortable: true,
|
||||
accessor: custPhone,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-1.5 text-body-sm text-ink-1">
|
||||
<Phone className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
{custPhone(row) || '—'}
|
||||
</p>
|
||||
{custEmail(row) ? (
|
||||
<p className="flex items-center gap-1.5 truncate text-caption text-ink-3">
|
||||
<Mail className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
{custEmail(row)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bookings',
|
||||
header: 'Bookings',
|
||||
sortValue: custBookings,
|
||||
accessor: (row) => number(custBookings(row), '0'),
|
||||
align: 'right',
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<IconButton label="Edit customer" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title="Customers"
|
||||
subtitle="The people at the receiving end of every booking"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/doormile/customer/create')}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New customer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={stats.total}
|
||||
label="Total customers"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Mail}
|
||||
value={stats.withEmail}
|
||||
label={`With email · ${percentOf(stats.withEmail, stats.total)}%`}
|
||||
tone="navy"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('withEmail')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Phone}
|
||||
value={stats.withPhone}
|
||||
label={`With phone · ${percentOf(stats.withPhone, stats.total)}%`}
|
||||
tone="accent"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('withPhone')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Package}
|
||||
value={number(stats.totalBookings, '0')}
|
||||
label="Bookings placed"
|
||||
tone="success"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by name, phone or email"
|
||||
count={{ shown: rows.length, total: customers.length, noun: 'customers' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => custId(row)}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'customer', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No customers yet"
|
||||
description="Customers appear here as bookings are placed, or add one directly."
|
||||
action={{ label: 'New customer', icon: Plus, onClick: () => navigate('/doormile/customer/create') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editRow)}
|
||||
onOpenChange={(open) => !open && setEditRow(null)}
|
||||
title="Edit customer"
|
||||
description={`ID #${custId(editRow || {}) ?? ''}`}
|
||||
icon={Users}
|
||||
busy={updateCustomer.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setEditRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={updateCustomer.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
<Field label="Customer name" hint="Stored as first and last name — split on the first space">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Anitha Raman"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Phone">
|
||||
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="9876543210" />
|
||||
</Field>
|
||||
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
placeholder="anitha@example.com"
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
693
src/pages/doormile/deliveries/Deliveries.jsx
Normal file
693
src/pages/doormile/deliveries/Deliveries.jsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input,
|
||||
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
||||
Stack, StatusBadge, Surface, Tabs, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useBooking, useCancelDelivery, useChangeDeliveryRider, useDeliveries,
|
||||
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
|
||||
useUpdateDeliveryStatus,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Deliveries — orders that have moved past merely being created.
|
||||
*
|
||||
* The rows are built from `/admin/bookings` filtered to those with a rider or a
|
||||
* consignment, joined against customers, riders, clients and consignments; see
|
||||
* `fetchDeliveries` for why the consignments endpoint cannot be the source on
|
||||
* its own, and for which columns are deliberately left blank rather than filled
|
||||
* with numbers the API never returned.
|
||||
*
|
||||
* A row's status comes from its consignment when it has one. A booking freezes
|
||||
* at `Converted_To_Consignment` the moment it is picked up, so a row can
|
||||
* legitimately read "Active" while the booking still says otherwise — which is
|
||||
* why the table can say where the value came from.
|
||||
*/
|
||||
|
||||
/* `other` is not declared here — it is appended only when rows actually land
|
||||
in it, so the strip never carries a permanently empty tab. */
|
||||
const STATUS_TABS = [
|
||||
{ value: 'pending', label: 'Pending', countKey: 'uncoveredLength' },
|
||||
{ value: 'accepted', label: 'Accepted', countKey: 'assignedLength' },
|
||||
{ value: 'arrived', label: 'Arrived', countKey: 'arrivedLength' },
|
||||
{ value: 'picked', label: 'Picked', countKey: 'pickedLength' },
|
||||
{ value: 'active', label: 'Active', countKey: 'activeLength' },
|
||||
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
|
||||
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
|
||||
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
|
||||
];
|
||||
|
||||
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
|
||||
|
||||
const BATCH_OPTIONS = [
|
||||
{ id: 'all', label: 'All batches', range: 'Across the day' },
|
||||
...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })),
|
||||
];
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
/** The statuses this console can actually write to a consignment. */
|
||||
const SETTABLE_STATUSES = [
|
||||
{ value: 'active', label: 'Out for delivery' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
export default function Deliveries() {
|
||||
// Unscoped by default. `fetchDeliveries` treats a blank from/to as "no
|
||||
// bound" (see queries.js — `if (!startdate && !enddate) return true`), and
|
||||
// it filters on `orderdate` (the booking's creation day), never on when a
|
||||
// row was last touched. Defaulting this range to today meant the Pending/
|
||||
// Accepted/Arrived/Picked/Active tabs — an outstanding-work QUEUE, not a
|
||||
// day's log — only showed a booking that was also *created* today.
|
||||
// Reassign an order that came in yesterday and it's still correctly
|
||||
// "Pending" work, but it dropped off the board the moment you did it,
|
||||
// reading as "I just assigned it and it's not there." Same "state vs
|
||||
// flow" distinction this app already draws elsewhere for orders. Delivered/
|
||||
// Cancelled stay meaningful to scope by date — the operator narrows via
|
||||
// the date inputs same as before.
|
||||
const [range, setRange] = useState({ from: '', to: '' });
|
||||
const [tab, setTab] = useState('pending');
|
||||
// 'all' rather than the wave matching the current clock hour: a batch is a
|
||||
// slice of ONE day, so "jump to the slot matching right now" only means
|
||||
// something once the date range above is scoped to a single day. With the
|
||||
// range unscoped by default, defaulting the batch too would silently hide
|
||||
// almost everything — a booking created three days ago at 11am has no
|
||||
// relationship to "the slot matching right now" on a different day.
|
||||
const [batch, setBatch] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [tenantId, setTenantId] = useState(ALL);
|
||||
const [riderId, setRiderId] = useState(ALL);
|
||||
|
||||
const [detailRow, setDetailRow] = useState(null);
|
||||
const [riderRow, setRiderRow] = useState(null);
|
||||
const [selectedRider, setSelectedRider] = useState('');
|
||||
const [statusRow, setStatusRow] = useState(null);
|
||||
const [nextStatus, setNextStatus] = useState('delivered');
|
||||
const [cancelRow, setCancelRow] = useState(null);
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({
|
||||
page,
|
||||
pageSize: 100,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
const { data: counts } = useDeliveryCounts();
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
const { data: tenants = [] } = useTenants();
|
||||
|
||||
const changeRider = useChangeDeliveryRider();
|
||||
const updateStatus = useUpdateDeliveryStatus();
|
||||
const cancelDelivery = useCancelDelivery();
|
||||
const notifyMiler = useNotifyMiler();
|
||||
|
||||
const allRows = data?.rows || [];
|
||||
|
||||
/* Batch, client and rider first, then status, then search — the counts under
|
||||
the tabs have to describe the slice the operator has chosen, not the whole
|
||||
day, or a tab reads 6 and shows 2.
|
||||
|
||||
There is deliberately no site filter: a delivery row carries its client but
|
||||
no tenant-location id, so the only honest filter at that level would be one
|
||||
that silently matched nothing. */
|
||||
const batchRows = useMemo(
|
||||
() =>
|
||||
allRows
|
||||
.filter((row) => batch === 'all' || getRowBatchId(row) === batch)
|
||||
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
||||
.filter((row) => riderId === ALL || String(row.userid) === riderId),
|
||||
[allRows, batch, tenantId, riderId]
|
||||
);
|
||||
|
||||
const unmapped = useMemo(
|
||||
() => batchRows.filter((row) => !KNOWN_STATUSES.includes(String(row.orderstatus || '').toLowerCase())),
|
||||
[batchRows]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const base = STATUS_TABS.map((entry) => ({
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
count: batchRows.filter((row) => String(row.orderstatus || '').toLowerCase() === entry.value).length,
|
||||
}));
|
||||
/* A row whose status matches no tab used to be counted nowhere and shown
|
||||
nowhere. It gets its own tab, but only when one actually exists. */
|
||||
if (unmapped.length) base.push({ value: 'other', label: 'Other', count: unmapped.length });
|
||||
return base;
|
||||
}, [batchRows, unmapped]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const scoped =
|
||||
tab === 'other'
|
||||
? unmapped
|
||||
: batchRows.filter((row) => {
|
||||
const status = String(row.orderstatus || '').toLowerCase();
|
||||
return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab;
|
||||
});
|
||||
|
||||
return scoped.filter((row) =>
|
||||
matchesQuery(
|
||||
row,
|
||||
['orderid', 'deliverycustomer', 'ridername', 'pickupaddress', 'deliveryaddress', 'tenantname'],
|
||||
debouncedSearch
|
||||
)
|
||||
);
|
||||
}, [batchRows, tab, unmapped, debouncedSearch]);
|
||||
|
||||
/* Whether anything is narrowing the list — drives the empty state's wording
|
||||
and whether Clear filters is offered. It has to name every filter, or the
|
||||
button appears and then visibly fails to clear one of them. */
|
||||
const isFiltered =
|
||||
Boolean(debouncedSearch) || batch !== 'all' || tenantId !== ALL || riderId !== ALL;
|
||||
|
||||
/* A new filter re-scopes the list, so page 1 is the only page that still
|
||||
means anything. */
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [tab, batch, tenantId, riderId, debouncedSearch, range.from, range.to]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'orderid',
|
||||
header: 'Order & client',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(row.tenantname)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'route',
|
||||
header: 'Route (pickup → drop)',
|
||||
sortValue: (row) => row.pickupaddress || '',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
<span className="font-medium text-ink-1">Pickup</span>
|
||||
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
<span className="font-medium text-ink-2">{orDash(row.deliverycustomer)}</span>
|
||||
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ridername',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-1">{orDash(row.ridername)}</p>
|
||||
<p className="text-caption text-ink-3">{orDash(row.ridercontact)}</p>
|
||||
</div>
|
||||
),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'eta',
|
||||
header: 'ETA & distance',
|
||||
sortValue: (row) => row.expecteddeliverytime || '',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm text-ink-2">
|
||||
{formatDoormileTimestamp(row.expecteddeliverytime, 'hh:mm A')}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">{formatKm(row.kms)}</p>
|
||||
</div>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliverycharges',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliverycharges) || 0,
|
||||
accessor: (row) => currency(row.deliverycharges),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'orderstatus',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<StatusBadge status={row.orderstatus} dot size="sm" />
|
||||
{/* Where the value came from. Without this, a row reading "Active"
|
||||
while the booking still says Converted_To_Consignment looks
|
||||
exactly like a bug. */}
|
||||
{row.statusfromconsignment ? (
|
||||
<span className="text-[10px] text-ink-4">from consignment</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
label="Order details"
|
||||
icon={PackageSearch}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDetailRow(row)}
|
||||
/>
|
||||
|
||||
{row.orderstatus !== 'delivered' && row.milerprofileid ? (
|
||||
<IconButton
|
||||
label="Notify rider"
|
||||
icon={Bell}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
notifyMiler.mutate({
|
||||
milerProfileId: row.milerprofileid,
|
||||
title: 'DoormileXpress',
|
||||
message: 'Orders have been placed for delivery. Kindly accept and process deliveries.',
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
|
||||
<IconButton
|
||||
label="Change rider"
|
||||
icon={Bike}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRiderRow(row);
|
||||
setSelectedRider('');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IconButton
|
||||
label="Update status"
|
||||
icon={Truck}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusRow(row);
|
||||
setNextStatus('delivered');
|
||||
}}
|
||||
/>
|
||||
|
||||
{!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? (
|
||||
<IconButton
|
||||
label="Cancel delivery"
|
||||
icon={Ban}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCancelRow(row);
|
||||
setCancelReason('');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[notifyMiler]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ key: 'orderid', header: 'Order' },
|
||||
{ key: 'tenantname', header: 'Client' },
|
||||
{ key: 'deliverycustomer', header: 'Customer' },
|
||||
{ key: 'ridername', header: 'Rider' },
|
||||
{ key: 'orderstatus', header: 'Status' },
|
||||
{ key: 'kms', header: 'Km' },
|
||||
{ key: 'deliverycharges', header: 'Amount' },
|
||||
{ key: 'orderdate', header: 'Created' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Truck}
|
||||
title="Deliveries"
|
||||
subtitle={`${counts?.total ?? 0} dispatched · ${
|
||||
!range.from && !range.to
|
||||
? 'All dates'
|
||||
: range.from === range.to
|
||||
? range.from
|
||||
: `${range.from || '…'} → ${range.to || '…'}`
|
||||
}`}
|
||||
actions={
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.from}
|
||||
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
|
||||
aria-label="From date"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.to}
|
||||
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
|
||||
aria-label="To date"
|
||||
className="w-40"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
||||
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by order, customer, rider or address"
|
||||
count={{ shown: rows.length, total: batchRows.length, noun: 'deliveries' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, exportColumns, `deliveries-${tab}-${range.from || 'all'}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={batch} onValueChange={setBatch}>
|
||||
<SelectTrigger className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BATCH_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label} · {option.range}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={tenantId} onValueChange={setTenantId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All clients</SelectItem>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={riderId} onValueChange={setRiderId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All riders" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All riders</SelectItem>
|
||||
{(riders || []).map((rider) => (
|
||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||
{rider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.orderheaderid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setBatch('all');
|
||||
setTenantId(ALL);
|
||||
setRiderId(ALL);
|
||||
}}
|
||||
emptyState={
|
||||
isFiltered ? undefined : (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Nothing dispatched here"
|
||||
description="A booking appears once it has a rider or has become a consignment."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{data?.nextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={() => setPage(data.nextPage)} loading={isFetching}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={Boolean(riderRow)}
|
||||
onOpenChange={(open) => !open && setRiderRow(null)}
|
||||
title="Change rider"
|
||||
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
|
||||
icon={Bike}
|
||||
busy={changeRider.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRiderRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const rider = (riders || []).find((entry) => String(entry.userid) === selectedRider);
|
||||
if (!rider) return;
|
||||
changeRider.mutate(
|
||||
{ rider, row: riderRow },
|
||||
{ onSuccess: (res) => res?.success !== false && setRiderRow(null) }
|
||||
);
|
||||
}}
|
||||
disabled={!selectedRider || changeRider.isPending}
|
||||
>
|
||||
Reassign
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Rider">
|
||||
<Select value={selectedRider} onValueChange={setSelectedRider}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a rider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(riders || []).map((rider) => (
|
||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||
{rider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(statusRow)}
|
||||
onOpenChange={(open) => !open && setStatusRow(null)}
|
||||
title="Update status"
|
||||
description={statusRow ? `Order ${statusRow.orderid}` : undefined}
|
||||
icon={Truck}
|
||||
busy={updateStatus.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setStatusRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateStatus.mutate(
|
||||
{ ...statusRow, orderstatus: nextStatus },
|
||||
{ onSuccess: (res) => res?.success !== false && setStatusRow(null) }
|
||||
)
|
||||
}
|
||||
disabled={updateStatus.isPending || !statusRow?.consignmentid}
|
||||
>
|
||||
Update status
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
{statusRow && !statusRow.consignmentid ? (
|
||||
/* The write is consignment-scoped and only exists once the rider
|
||||
marks pickup complete. Blocking here with the reason beats
|
||||
letting the submit silently do nothing. */
|
||||
<Alert tone="warning" title="Not picked up yet">
|
||||
Status can only be updated once this order becomes a consignment — that happens when the rider completes
|
||||
pickup.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="New status"
|
||||
hint="Pending, Accepted, Arrived, Picked and Skipped have no equivalent on the consignment API and cannot be set from here."
|
||||
>
|
||||
<Select value={nextStatus} onValueChange={setNextStatus} disabled={!statusRow?.consignmentid}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SETTABLE_STATUSES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(cancelRow)}
|
||||
onOpenChange={(open) => !open && setCancelRow(null)}
|
||||
title="Cancel this delivery"
|
||||
description={cancelRow ? `Order ${cancelRow.orderid}` : undefined}
|
||||
busy={cancelDelivery.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCancelRow(null)}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
cancelDelivery.mutate(
|
||||
{ row: cancelRow, reason: cancelReason },
|
||||
{ onSettled: () => setCancelRow(null) }
|
||||
)
|
||||
}
|
||||
loading={cancelDelivery.isPending}
|
||||
>
|
||||
Cancel delivery
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Reason">
|
||||
<Textarea rows={3} value={cancelReason} onChange={(e) => setCancelReason(e.target.value)} />
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is actually in the parcel.
|
||||
*
|
||||
* The table row is built from the bulk bookings feed, which carries no parcel
|
||||
* contents — so this fetches the single booking on open rather than showing a
|
||||
* panel of blanks. A parcel has no quantity field: N items arrive as N entries,
|
||||
* which is why the count here is the length of the list.
|
||||
*/
|
||||
function OrderDetailDrawer({ row, onClose }) {
|
||||
const { data: booking, isLoading } = useBooking(row?.orderheaderid);
|
||||
const parcels = booking?.parcels || [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(row)}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Order ${row?.orderid ?? ''}`}
|
||||
description={row?.tenantname || undefined}
|
||||
icon={PackageSearch}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-body-sm text-ink-3">Loading order…</p>
|
||||
) : (
|
||||
<Stack space="default">
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
['Status', row?.orderstatus],
|
||||
['Rider', row?.ridername],
|
||||
['Customer', row?.deliverycustomer],
|
||||
['Contact', row?.deliverycontactno],
|
||||
['Charge', currency(row?.deliverycharges)],
|
||||
['Distance', formatKm(row?.kms)],
|
||||
['Placed', formatDoormileTimestamp(row?.orderdate)],
|
||||
['Expected', formatDoormileTimestamp(row?.expecteddeliverytime)],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
|
||||
<dd className="text-body-sm text-ink-1">{orDash(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Surface>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(row?.pickupaddress)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
|
||||
<p className="text-body-sm text-ink-2">{orDash(row?.deliveryaddress)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">
|
||||
Parcels {parcels.length ? `· ${parcels.length}` : ''}
|
||||
</p>
|
||||
{parcels.length === 0 ? (
|
||||
<p className="text-body-sm text-ink-3">No parcel detail recorded on this order.</p>
|
||||
) : (
|
||||
<Stack space="xs">
|
||||
{parcels.map((parcel, index) => (
|
||||
<Surface key={index} variant="subtle" padding="sm" radius="lg">
|
||||
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{orDash(parcel.itemdescription)}
|
||||
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
|
||||
</p>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{row?.notes ? (
|
||||
<div>
|
||||
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>
|
||||
<p className="text-body-sm text-ink-2">{row.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
245
src/pages/doormile/dispatch/ActiveSection.jsx
Normal file
245
src/pages/doormile/dispatch/ActiveSection.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
MdTwoWheeler,
|
||||
MdLocationOn,
|
||||
MdRestaurant,
|
||||
MdMyLocation,
|
||||
MdAccessTime,
|
||||
MdInventory2
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { List, ListItem } from '@astryxdesign/core/List';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import LoaderWithImage from 'components/nearle_components/LoaderWithImage';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { AccentAvatar } from 'themes/dt/primitives';
|
||||
import { getActiveOrder } from './dispatchShared';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
|
||||
// ==============================|| ACTIVE DELIVERIES (sidebar) ||============================== //
|
||||
// Rows, not cards. Astryx's design rule is that dense operator data reads as
|
||||
// edge-to-edge rows — the old `.adcard` bordered cards cost ~16px of chrome per
|
||||
// delivery, which is a lot of scrolling once a slot has 20+ riders out.
|
||||
//
|
||||
// Status comes from <StatusBadge> (themes/dt/status.js), which is now the only
|
||||
// status-colour table in this folder — dispatchShared's rival copy has been
|
||||
// deleted and its getStatusStyle reduced to an adapter over the same source.
|
||||
|
||||
// Small labelled metric — icon + value, used for the distance/ETA pair.
|
||||
// The old markup carried a native `title` for the label ("Distance to drop");
|
||||
// Astryx's BaseProps deliberately omits `title`, so the affordance moves to the
|
||||
// real <Tooltip> rather than silently disappearing onto a dropped attribute.
|
||||
const Metric = ({ icon, children, label }) => (
|
||||
<Tooltip content={label}>
|
||||
<HStack gap={0.5} vAlign="center">
|
||||
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
|
||||
{icon}
|
||||
</Center>
|
||||
<Text size="sm" type="supporting" hasTabularNumbers>
|
||||
{children}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
Metric.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
label: PropTypes.string
|
||||
};
|
||||
|
||||
// One line of secondary context (rider / drop area / pickup) under the customer.
|
||||
// `maxLines={1}` turns on Text's own truncate tooltip, which replaces the
|
||||
// `title` the old spans used to show the full value when clipped.
|
||||
const ContextLine = ({ icon, children }) => (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Center isInline width={13} height={13} style={{ flexShrink: 0, color: 'var(--color-icon-secondary)' }}>
|
||||
{icon}
|
||||
</Center>
|
||||
<Text size="sm" type="supporting" maxLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
ContextLine.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
const ActiveSection = ({
|
||||
visibleRiders,
|
||||
riders,
|
||||
focusedStop,
|
||||
handleRiderFocus,
|
||||
setFocusedStop,
|
||||
calculateEstMeters,
|
||||
getRiderColor,
|
||||
formatMeters,
|
||||
isLoading
|
||||
}) => {
|
||||
// Sort by live distance ascending (closest drop-off first; null/unknown last)
|
||||
const activeDeliveries = visibleRiders
|
||||
.map((r) => getActiveOrder(r.orders))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const mA = calculateEstMeters(a.rider_id || a.userid, a) ?? Infinity;
|
||||
const mB = calculateEstMeters(b.rider_id || b.userid, b) ?? Infinity;
|
||||
return mA - mB;
|
||||
});
|
||||
|
||||
// Toast notifications: detect status transitions across renders
|
||||
const prevStatusMapRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentMap = {};
|
||||
visibleRiders.forEach((r) => {
|
||||
(r.orders || []).forEach((o) => {
|
||||
currentMap[String(o.orderid)] = {
|
||||
status: String(o.orderstatus || '').toLowerCase(),
|
||||
riderName: o.rider_name || o.ridername || 'Rider',
|
||||
customer: o.deliverycustomer || o.customername || `Order #${o.orderid}`
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const prev = prevStatusMapRef.current;
|
||||
if (prev !== null) {
|
||||
Object.entries(currentMap).forEach(([oid, cur]) => {
|
||||
const old = prev[oid];
|
||||
if (!old) {
|
||||
// Brand-new order that is already active → rider has taken a new order
|
||||
if (cur.status === 'active') {
|
||||
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Rider transitioned into active → started heading to customer
|
||||
if (old.status !== 'active' && cur.status === 'active') {
|
||||
OpenToast(`🛵 ${cur.riderName} is now delivering to ${cur.customer}`, 'success', 3000);
|
||||
}
|
||||
// Rider delivered → reached customer location
|
||||
if (old.status === 'active' && cur.status === 'delivered') {
|
||||
OpenToast(`${cur.riderName} has reached ${cur.customer}'s location`, 'info', 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prevStatusMapRef.current = currentMap;
|
||||
}, [visibleRiders]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<VStack gap={2} padding={4} hAlign="center" vAlign="center" minHeight={200}>
|
||||
<LoaderWithImage size={44} imgSize={24} />
|
||||
<Text type="supporting">Loading active deliveries…</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeDeliveries.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No active deliveries"
|
||||
description="No deliveries are currently in progress for this slot."
|
||||
icon={<MdInventory2 size={28} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderActiveDelivery = (o) => {
|
||||
const rid = o.rider_id || o.userid;
|
||||
const rider = riders.find((r) => String(r.id) === String(rid));
|
||||
const color = getRiderColor(rid);
|
||||
const lat = parseFloat(o.droplat || o.deliverylat);
|
||||
const lon = parseFloat(o.droplon || o.deliverylong);
|
||||
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
|
||||
const estMeters = calculateEstMeters(rid, o);
|
||||
const customer = o.deliverycustomer || o.customername || `Order #${o.orderid}`;
|
||||
const dropArea = o.deliverysuburb || o.deliveryaddress || o.zone_name || '';
|
||||
const riderName = o.rider_name || o.ridername || 'Unassigned';
|
||||
const isSelected = canFocus && focusedStop && String(focusedStop.orderid) === String(o.orderid);
|
||||
const initials =
|
||||
riderName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.toUpperCase() || '•';
|
||||
|
||||
const deliveredAt = o.deliverytime
|
||||
? parseDoormileTimestamp(o.deliverytime).isValid()
|
||||
? parseDoormileTimestamp(o.deliverytime).format('HH:mm:ss')
|
||||
: String(o.deliverytime)
|
||||
: null;
|
||||
|
||||
// 20 km/h is the same city-average assumption the old card used.
|
||||
const etaMin = estMeters !== null ? (estMeters / 1000 / 20) * 60 : null;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={o.orderid}
|
||||
label={customer}
|
||||
isSelected={!!isSelected}
|
||||
onClick={() => {
|
||||
if (rider) handleRiderFocus(rider);
|
||||
if (canFocus) setFocusedStop({ orderid: o.orderid, lat, lon });
|
||||
}}
|
||||
startContent={
|
||||
// Rider colour is an arbitrary per-rider hex assigned by the map's
|
||||
// palette, which no semantic token can express — this is the same
|
||||
// caller-supplied-accent escape hatch StatCard uses.
|
||||
<AccentAvatar color={color} selected size={32}>
|
||||
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
|
||||
{initials}
|
||||
</Text>
|
||||
</AccentAvatar>
|
||||
}
|
||||
description={
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<ContextLine icon={<MdTwoWheeler size={13} />}>{riderName}</ContextLine>
|
||||
{dropArea && <ContextLine icon={<MdLocationOn size={13} />}>{dropArea}</ContextLine>}
|
||||
{o.pickupcustomer && <ContextLine icon={<MdRestaurant size={13} />}>{o.pickupcustomer}</ContextLine>}
|
||||
</VStack>
|
||||
}
|
||||
endContent={
|
||||
<VStack gap={1} padding={0} hAlign="end">
|
||||
<StatusBadge status={o.orderstatus} />
|
||||
{deliveredAt && (
|
||||
<Text size="sm" type="supporting" hasTabularNumbers>
|
||||
{deliveredAt}
|
||||
</Text>
|
||||
)}
|
||||
{estMeters !== null && (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Metric icon={<MdMyLocation size={14} />} label="Distance to drop">
|
||||
{formatMeters(estMeters)}
|
||||
</Metric>
|
||||
<Metric icon={<MdAccessTime size={14} />} label="Estimated time to drop">
|
||||
{etaMin < 1 ? '< 1 min' : `${Math.ceil(etaMin)} min`}
|
||||
</Metric>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<List density="balanced" hasDividers>
|
||||
{activeDeliveries.map(renderActiveDelivery)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSection;
|
||||
165
src/pages/doormile/dispatch/CLAUDE.md
Normal file
165
src/pages/doormile/dispatch/CLAUDE.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# CLAUDE.md — `src/pages/nearle/dispatch/`
|
||||
|
||||
Rules for editing `Dispatch.js`, `Preview.js`, `CompareDataPanel.js`, and `dispatchShared.js`.
|
||||
**This is the most complex area of the codebase.** Dispatch.js alone is ~2500 lines, mixes Leaflet imperative APIs with React state, and shares its batch / time-bucket model with `deliveries.js`. Read this before touching anything here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Batch / wave model
|
||||
|
||||
Dispatch.js defines the canonical batch hour ranges. `deliveries.js` mirrors them — **the two pages must agree on which batch a given row belongs to**, otherwise the same delivery shows up in one batch on one page and a different batch on the other.
|
||||
|
||||
**The windows live in `utils/batchBucket.js` now.** Dispatch derives `BATCHES_DEFAULT_RAW` from it and `deliveries.js` derives `BATCH_OPTIONS` from it, so the two pages cannot disagree by construction. Only presentation (colour, icon) is page-local.
|
||||
|
||||
```js
|
||||
// utils/batchBucket.js — half-open [startHour, endHour) in LOCAL time, not UTC
|
||||
[
|
||||
{ id: 'morning', startHour: 0, endHour: 9 }, // 12 AM – 9 AM
|
||||
{ id: 'afternoon', startHour: 9, endHour: 16 }, // 9 AM – 4 PM
|
||||
{ id: 'evening', startHour: 16, endHour: 24 } // After 4 PM
|
||||
]
|
||||
```
|
||||
|
||||
**They cover the whole day, and that is load-bearing.** They used to be 0–8 / 9–12.5 / 16–19 — 14.5 of 24 hours, with gaps at 8–9 AM, 12:30–4 PM and after 7 PM, documented here as intentional. They weren't wrong when they were written: they came from jupiter, where bucketing ran on `expecteddeliverytime` and they described **promised delivery slots**, which really did cluster.
|
||||
|
||||
This app buckets on `orderdate` — when the order was *placed* — and orders are placed all day. A booking created at 2:43 PM landed in the 12:30–4 PM gap, belonged to no batch, and disappeared from every batch filter on both pages. On Dispatch, where batches are the primary navigation, that made the order invisible entirely. 40% of the clock was a black hole.
|
||||
|
||||
Each window now runs to the start of the next. **Every start hour is unchanged, so the change is strictly additive** — verified exhaustively minute by minute: zero rows move between batches, 570 minutes that previously had no batch now have one, and no minute belongs to two.
|
||||
|
||||
If you ever move bucketing back to a promised-delivery field, the clustered windows become correct again — but then re-read the table below first, because that field was already tried and rejected for a different reason.
|
||||
|
||||
### Time-field selection (`BATCH_TIME_FIELD`)
|
||||
Fixed at `'created'` → bucket key is `['orderdate']` (the booking's `createdat`). `deliveries.js` hardcodes the same key in `BATCH_TIME_KEYS`. If you change one, change both — they read each other's bucketing.
|
||||
|
||||
Note the five `TIME_FIELDS` entries that are now inert: this backend emits only `createdat`, `updatedat` and the SLA estimate, so `acceptedtime` / `starttime` / `arrivaltime` / `pickuptime` / `deliverytime` are always undefined. They're harmless while `selectedTimeField` is a constant — but if the operator-facing dropdown is ever resurrected, five of its eight options would silently bucket nothing.
|
||||
|
||||
This is a constant, not state. The operator-facing time-field dropdown and the slot-hour editor have both been **deleted** (they were commented-out dead JSX whose setters nothing called). Don't resurrect either without re-reading the ban below.
|
||||
|
||||
**Three fields have been tried here. Two were wrong:**
|
||||
|
||||
| Field | Why it failed |
|
||||
|---|---|
|
||||
| `assigntime` | Not an assignment time — `api.js` maps it to the booking's `updatedat`, which is re-stamped by any status change. Orders drifted into whichever batch held the current clock time. |
|
||||
| `expecteddeliverytime` | Stable, but it's the *promised delivery* slot, not the wave. An order created at 12:22 whose SLA put its ETA at 16:30 counted towards Evening, so Evening showed orders nobody had placed yet. |
|
||||
| `orderdate` (**current**) | Immutable, and matches the operator's model: an order created and assigned at 12:22 belongs to Afternoon. No batch later than the current clock time can show a count for today. |
|
||||
|
||||
### Date filtering is client-side
|
||||
`GET /admin/bookings` takes only `pageno`/`pagesize` — it has no date parameter. `fetchDeliveries` in `api.js` applies the `startdate`/`enddate` from the query key **client-side**, filtering on `orderdate` so it agrees with the bucket field above.
|
||||
|
||||
Those two key positions were previously destructured-but-ignored, which meant changing the date refetched and returned an identical row set — every batch count was the same for today, yesterday and last week. If you add a filter to this query, make sure `fetchDeliveries` actually reads it; a query key position that nothing consumes is a silent no-op.
|
||||
|
||||
#### ⛔ Don't admit rows on `updatedat` while bucketing on `createdat`
|
||||
|
||||
An opt-in "activity" date basis was added so an order booked yesterday and still moving today would appear on today's board, then **reverted**. Admitting a row on one timestamp while bucketing it on another cannot produce an honest batch: an order created yesterday at 6 PM and merely touched today was let onto today's board and then bucketed by its creation hour, so **Evening Batch read 6 at 10:41 in the morning on a day with no orders created at all**.
|
||||
|
||||
The filter field and the bucket field have to be the same field. If carried-over work needs to be visible on the board, it needs its own bucket ("Carried over") — not a time-of-day wave it does not belong to. Decide that before reaching for the date filter again.
|
||||
|
||||
### Picked is empty for hyperlocal traffic, and that is correct
|
||||
|
||||
`doormile-flow.md` §5: at `pickup-complete` the booking becomes a consignment, and **matching 3-digit pickup/delivery pincode prefixes go straight to `Out_for_Delivery`** (hyperlocal); everything else routes via a hub.
|
||||
|
||||
So a Coimbatore-to-Coimbatore order (641xxx → 641xxx) is picked up and is *immediately* out for delivery. It never rests in a "picked" state. The Picked tab reading 0 while orders are plainly moving is expected behaviour on hyperlocal traffic, not a missing mapping.
|
||||
|
||||
The confusing part is downstream of that: the booking's own `status` **freezes at `Converted_To_Consignment`** the moment it is picked up. The Deliveries page joins `GET /admin/consignments` and lets the consignment's status win, so a row correctly reads **Active** while `GET /admin/bookings` still says `Converted_To_Consignment`. That looks like a bug on inspection, which is why the status badge carries a tooltip naming the consignment and its status whenever the value came from there (`statusfromconsignment` on the row).
|
||||
|
||||
### ⛔ Never bucket on `assigntime`
|
||||
It is **not** an assignment time. The Doormile bookings feed has no assignment timestamp, so `api.js` maps `assigntime` to the booking's `updatedat` — its last-modified column. Any status change, parcel scan, payment or pickup-complete re-stamps it.
|
||||
|
||||
Bucketing on it (which both pages did until this was found) means an order silently leaves the batch it belongs to and joins whichever window contains the current clock time, so the same orders appear under Afternoon and then Evening on the same day. The Dispatch page's 15-second poll makes the counts move on their own.
|
||||
|
||||
Displaying it as a "last updated" stamp is fine — that's all the reports use it for. The true assignment record lives on `bookingassignments`, reachable only per-booking via `GET /admin/bookings/:id/track` (`{ booking, assignments[], riders[] }`); its `assignments[]` has not been observed non-empty, so it is not a verified source yet. If the backend ever exposes an `assignedat` on the list feed, that becomes the correct bucket key.
|
||||
|
||||
### Don'ts
|
||||
- Don't bucket in UTC. Use `dayjs(t)` (local), not `dayjs(t).utc()`. The original deliveries page had a UTC bucketing bug that hid orders mid-day; the multi-line comment above `getRowBatchId` in `deliveries.js` (search for `getRowBatchId`) explains it. Don't reintroduce.
|
||||
- Don't bucket bare `YYYY-MM-DD` strings — they parse to midnight and mis-bucket into Morning. Skip them.
|
||||
- Don't bucket on a timestamp that the backend re-stamps. See above.
|
||||
- Don't add a 4th batch without updating both pages and confirming with the backend what the new boundary means for assignments.
|
||||
|
||||
---
|
||||
|
||||
## 1b. Status colours — one table, and it isn't in this folder
|
||||
|
||||
`themes/dt/status.js` (`STATUS_META` / `getStatusMeta()`) is the **only** place a lifecycle status maps to a colour. Render statuses with `<StatusBadge status={...}/>` from `components/nearle_components/StatusBadge`.
|
||||
|
||||
This folder used to carry a rival `STATUS_STYLES` table in `dispatchShared.js`. It had drifted far enough to be actively misleading — `picked` and `active` were the *same* `#0ea5e9`, so the map showed a picked-up order and an in-transit order identically, while the Deliveries table showed them purple and teal. It has been deleted.
|
||||
|
||||
`dispatchShared.js` still exports **`getStatusStyle`**, but it is now a thin adapter over `getStatusMeta` returning `{ label, bg, fg }`. It exists for one reason: **Leaflet `divIcon` markers are built as HTML template strings**, and you cannot put a React component inside `L.divIcon({ html: '…' })`. That is the only sanctioned use.
|
||||
|
||||
- Rendering normal JSX? → `<StatusBadge>`.
|
||||
- Building a divIcon HTML string, or setting an inline `style` on a marker? → `getStatusStyle`.
|
||||
- Adding a status colour anywhere else in this folder? → don't; add it to `themes/dt/status.js`.
|
||||
|
||||
New backend status enums go in that file's `STATUS_ALIASES`, not in a local map. Unmapped values render as a neutral badge showing the raw string, so a missing alias is visible rather than silent.
|
||||
|
||||
---
|
||||
|
||||
## 1c. Astryx migration status for this folder
|
||||
|
||||
| File | State |
|
||||
|---|---|
|
||||
| `ActiveSection.js` | ✅ Astryx (List/ListItem rows, StatusBadge) |
|
||||
| `dispatchShared.js` | ✅ no status table; exports `onActivate`, `useIsNarrowViewport`, `getStatusStyle` adapter |
|
||||
| `ProfitabilitySection.js` | ✅ Astryx; its 1,596-line CSS is deleted |
|
||||
| `CompareDataPanel.js` | ✅ MUI-free (score ring stays hand-rolled CSS — `conic-gradient` has no Astryx equivalent) |
|
||||
| `Preview.js` | ✅ Astryx shell; **embeds `<Dispatch>`, which is still custom-CSS inside — a sanctioned temporary mix** |
|
||||
| `Dispatch.js` + `Dispatch.css` | ⏳ **not converted.** Brand colours and dead CSS purged; structure still hand-rolled `<div>`/className |
|
||||
|
||||
**The folder no longer imports `@mui/*` anywhere.** Don't reintroduce it.
|
||||
|
||||
`Dispatch.js` is the remaining work and must follow the staged order in the plan: pure-refactor extraction of the safe presentational pieces first (header chrome, mode/batch row, KPI strip, sidebar list, GPS modal, popup chrome) with **zero** visual change, then convert each piece to Astryx individually, then purge its CSS region by region. Never extract the marker registry, `AnimatedRiderMarker`, the `MapContainer` tree, or the marker event handlers, and never move the state — only the rendering of already-computed values.
|
||||
|
||||
---
|
||||
|
||||
## 2. Leaflet integration
|
||||
|
||||
Dispatch.js uses `react-leaflet` for declarative tile/marker rendering, BUT a lot of marker behaviour is imperative:
|
||||
|
||||
- **Marker icons:** The default leaflet marker icons are loaded from a CDN at module top (line ~454) via `L.Icon.Default.mergeOptions`. Webpack can't bundle the default sprite, so don't remove this shim.
|
||||
- **Polyline offset:** `import '../../../utils/leafletPolylineOffset'` adds a polyline-offset method to leaflet. Required for showing two riders on the same road segment with parallel polylines. Don't remove the import; the symbol it adds is used at the prototype level.
|
||||
- **Imperative marker registry:** A `useRef` map holds Leaflet marker instances keyed by `orderid`. Opening/closing popups is done by calling `marker.openPopup()` / `closePopup()` directly — *not* by setting React state. This is intentional; flipping state caused full-list re-renders and dropped frames.
|
||||
- **Popup overlay:** The "centered" popup the operator sees on hover is rendered outside Leaflet (it's a plain MUI dialog absolutely-positioned over the map). Leaflet's `<Popup>` attached to the marker is for click context only. Don't try to consolidate them.
|
||||
|
||||
---
|
||||
|
||||
## 3. The reconcile rule (re-stated because it's load-bearing)
|
||||
|
||||
After **any** manual edit on `/doormile/dispatch/preview` (drag-and-drop step reorder, swap rider, change delivery sequence), the page **must** call `POST routes.workolik.com/optimization/reconcile-steps` before `POST jupiter.nearle.app/deliveries/createdeliveries`.
|
||||
|
||||
Skipping reconcile corrupts route sequences in the database. This is the single biggest production bug to avoid in this area.
|
||||
|
||||
---
|
||||
|
||||
## 4. State that drives the live map
|
||||
|
||||
- `riders` — array of rider objects with latest GPS. Updated by polling `/utils/getriderperiodiclogs`.
|
||||
- `selectedRider` — currently focused rider. Triggers polyline highlight + popup reveal.
|
||||
- `batchCounts` — derived (via `useMemo`) from the loaded order list filtered by the active batch. Mirror this shape on `deliveries.js`.
|
||||
- `selectedTimeField` — which timestamp drives bucketing. See §1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance constraints
|
||||
|
||||
The dispatch page renders 100+ markers and polylines on every render. Watch for these regressions:
|
||||
|
||||
- Don't pass new object literals to memoised child components (`{ size: 12 }`-style props re-trigger re-render). Lift to refs or `useMemo`.
|
||||
- Don't put `setSelectedRider` inside a `useEffect` that depends on `riders` — that causes polling-loop re-renders.
|
||||
- Don't `console.log` inside a marker click handler in production paths.
|
||||
|
||||
---
|
||||
|
||||
## 6. Editing Preview.js specifically
|
||||
|
||||
`Preview.js` is the post-solver staging page. Receives the solver output, lets the operator drag-and-drop, then commits.
|
||||
|
||||
- Drag-and-drop uses `react-dnd` with `react-dnd-html5-backend`. Don't swap libraries.
|
||||
- After every drop, debounce a call to `reconcileSteps` from `api.js`. Don't call it synchronously on every drag tick — the optimiser will rate-limit you.
|
||||
- The "Assign" button calls `finalCreatedeliveries` → triggers `notifyRider` for each rider in the payload → redirects to `/doormile/deliveries`. Don't reorder these three steps.
|
||||
|
||||
---
|
||||
|
||||
## 7. File expectations for new work
|
||||
|
||||
- New solver mode? Add a new constant in `dispatchShared.js`, wire it through `orders.js` (the mode selector), and add the corresponding endpoint to `api.js`. Don't hardcode a new URL inside Dispatch.js or Preview.js.
|
||||
- New rider attribute on the live map? Add it to the rider-popup component in `Dispatch.js`, not to a new component — Leaflet popup mounting is fragile across React tree changes.
|
||||
- Don't break `CompareDataPanel.js` — it's used by the QA team to A/B different solver runs.
|
||||
1093
src/pages/doormile/dispatch/CompareDataPanel.jsx
Normal file
1093
src/pages/doormile/dispatch/CompareDataPanel.jsx
Normal file
File diff suppressed because it is too large
Load Diff
10606
src/pages/doormile/dispatch/Dispatch.css
Normal file
10606
src/pages/doormile/dispatch/Dispatch.css
Normal file
File diff suppressed because it is too large
Load Diff
2
src/pages/doormile/dispatch/Dispatch.js
Normal file
2
src/pages/doormile/dispatch/Dispatch.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './Dispatch.jsx';
|
||||
export * from './Dispatch.jsx';
|
||||
6703
src/pages/doormile/dispatch/Dispatch.jsx
Normal file
6703
src/pages/doormile/dispatch/Dispatch.jsx
Normal file
File diff suppressed because it is too large
Load Diff
2
src/pages/doormile/dispatch/Preview.js
Normal file
2
src/pages/doormile/dispatch/Preview.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './Preview.jsx';
|
||||
export * from './Preview.jsx';
|
||||
1020
src/pages/doormile/dispatch/Preview.jsx
Normal file
1020
src/pages/doormile/dispatch/Preview.jsx
Normal file
File diff suppressed because it is too large
Load Diff
627
src/pages/doormile/dispatch/ProfitabilitySection.jsx
Normal file
627
src/pages/doormile/dispatch/ProfitabilitySection.jsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import {
|
||||
MdTrendingUp,
|
||||
MdTrendingDown,
|
||||
MdReceipt,
|
||||
MdPayments,
|
||||
MdRoute,
|
||||
MdLocationOn,
|
||||
MdPeopleAlt,
|
||||
MdStraighten,
|
||||
MdAccountBalanceWallet,
|
||||
MdOutlineSavings
|
||||
} from 'react-icons/md';
|
||||
|
||||
import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { KpiGrid, TABLE_FRAME } from 'components/nearle_components/PageLayout';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Collapsible } from '@astryxdesign/core/Collapsible';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
import { AccentAvatar, TableScroll } from 'themes/dt/primitives';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Exported so reports/profitability.js can reuse the exact same profit math
|
||||
// instead of hand-duplicating it (a duplication that had already drifted
|
||||
// into a sync-risk between the two files).
|
||||
|
||||
/** Revenue rule: ₹30 base for ≤8 km, ₹6/km beyond. */
|
||||
export const BASE_REVENUE = 30;
|
||||
export const BASE_KM_LIMIT = 8;
|
||||
export const EXTRA_RATE_KM = 6;
|
||||
|
||||
/** Fixed salary cost sliced per slot (₹5000 / 30 days / 1 slot). */
|
||||
export const FIXED_COST_PER_SLOT = 500 / 3;
|
||||
|
||||
/** Variable fuel / wear cost per km. */
|
||||
export const VARIABLE_RATE_KM = 2.5;
|
||||
|
||||
// Status colours previously lived in a local STATUS_MAP here — the third rival
|
||||
// status table in this folder. It disagreed with both of the others (it painted
|
||||
// `active` sky-blue and had an `assigned` state the canonical table calls
|
||||
// `accepted`), so the same order could render three different colours depending
|
||||
// on which panel drew it. Statuses now render via <StatusBadge>, which reads
|
||||
// themes/dt/status.js. See this folder's CLAUDE.md §1b.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Pure helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function orderRevenue(order) {
|
||||
const km = parseFloat(order.riderkms || 0);
|
||||
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
|
||||
}
|
||||
|
||||
// NOTE: a fourth copy of the batch windows. Dispatch.js (BATCHES_DEFAULT_RAW),
|
||||
// deliveries.js and this file must agree — see the dispatch folder's CLAUDE.md
|
||||
// §1. reports/profitability.js imports this one, which is why it stays exported
|
||||
// here rather than moving wholesale into dispatchShared.
|
||||
export const BATCHES_DEFAULT = [
|
||||
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||
];
|
||||
|
||||
const getBatchForHour = (h, batches = BATCHES_DEFAULT) => {
|
||||
for (const b of batches) {
|
||||
if (h >= b.startHour && h < b.endHour) return b.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// The row field a slot is derived from. Must match Dispatch.js's
|
||||
// BATCH_TIME_FIELD ('created' → `orderdate`) and deliveries.js's
|
||||
// BATCH_TIME_KEYS.
|
||||
//
|
||||
// This used to read `assigntime || deliverydate` with a bare `dayjs()`. Both
|
||||
// halves were wrong: `assigntime` is mapped to the booking's last-modified
|
||||
// column (api.js), so it is re-stamped by any status change and a rider's slot
|
||||
// count drifted through the day; and bare `dayjs()` mis-parses the false
|
||||
// trailing Z some Doormile timestamps carry, shifting a row +5:30 into a
|
||||
// different slot. Slot count feeds the FIXED cost, so both bugs moved money.
|
||||
const getRowBatch = (r, batches = BATCHES_DEFAULT) => {
|
||||
const t = r?.orderdate;
|
||||
if (!t) return null;
|
||||
const str = String(t).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||
const d = parseDoormileTimestamp(t);
|
||||
if (!d.isValid()) return null;
|
||||
return getBatchForHour(d.hour() + d.minute() / 60, batches);
|
||||
};
|
||||
|
||||
// The calendar day a row belongs to — same field as the bucketing above, and
|
||||
// the same field api.js's fetchDeliveries filters its date range on.
|
||||
const rowDay = (o) => {
|
||||
const t = o?.orderdate;
|
||||
if (!t) return null;
|
||||
const d = parseDoormileTimestamp(t);
|
||||
return d.isValid() ? d.format('YYYY-MM-DD') : null;
|
||||
};
|
||||
|
||||
// Orders in these statuses never delivered, so they earn no revenue and
|
||||
// shouldn't be charged fuel/slot cost either — including them here made
|
||||
// every rider's profit/margin KPI count cancelled and skipped stops as if
|
||||
// they'd been completed.
|
||||
const EXCLUDED_FROM_PROFIT_STATUSES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
function calcRiderMetrics(rider, selectedDate, batches) {
|
||||
// `orders` stays the full date-filtered set — the breakdown table and
|
||||
// order-count KPI intentionally still show cancelled/skipped stops for
|
||||
// context. Only the money/km math below excludes them.
|
||||
const orders = (rider.orders ?? []).filter((o) => {
|
||||
if (!selectedDate) return true;
|
||||
return rowDay(o) === selectedDate;
|
||||
});
|
||||
const billableOrders = orders.filter((o) => {
|
||||
const status = String(o.orderstatus ?? o.status ?? '').toLowerCase();
|
||||
return !EXCLUDED_FROM_PROFIT_STATUSES.has(status);
|
||||
});
|
||||
let revenue = 0;
|
||||
let kms = 0;
|
||||
const slotsByDate = {};
|
||||
|
||||
for (const o of billableOrders) {
|
||||
revenue += orderRevenue(o);
|
||||
kms += parseFloat(o.riderkms || 0);
|
||||
|
||||
const slot = getRowBatch(o, batches);
|
||||
if (!slot) continue;
|
||||
|
||||
const dateStr = rowDay(o);
|
||||
if (!dateStr) continue;
|
||||
|
||||
if (!slotsByDate[dateStr]) {
|
||||
slotsByDate[dateStr] = new Set();
|
||||
}
|
||||
slotsByDate[dateStr].add(slot);
|
||||
}
|
||||
|
||||
let slotCount = 0;
|
||||
Object.values(slotsByDate).forEach((set) => {
|
||||
slotCount += Math.min(set.size, 3);
|
||||
});
|
||||
|
||||
const varCost = kms * VARIABLE_RATE_KM;
|
||||
const fixedCost = slotCount * FIXED_COST_PER_SLOT;
|
||||
const totalCost = varCost + fixedCost;
|
||||
const net = revenue - totalCost;
|
||||
const margin = revenue > 0 ? (net / revenue) * 100 : 0;
|
||||
|
||||
return { revenue, kms, varCost, fixedCost, totalCost, net, margin, orders };
|
||||
}
|
||||
|
||||
/** Format a km value, switching to metres below 1 km so short hops don't round to "0.0 km". */
|
||||
function formatDistance(km) {
|
||||
const value = Number.isFinite(km) ? km : 0;
|
||||
if (value > 0 && value < 1) {
|
||||
return `${Math.round(value * 1000)} m`;
|
||||
}
|
||||
return `${value.toFixed(1)} km`;
|
||||
}
|
||||
|
||||
function rupees(v, decimals = 0) {
|
||||
if (v == null) return '—';
|
||||
return `₹${parseFloat(v).toFixed(decimals)}`;
|
||||
}
|
||||
|
||||
function riderInitials(name) {
|
||||
if (!name) return '?';
|
||||
return (
|
||||
name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase() || '?'
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// Money is not a lifecycle status, so it must NOT go through StatusBadge —
|
||||
// it reads the raw hexes from the STATUS palette instead, which is the
|
||||
// sanctioned "arbitrary accent" channel (root CLAUDE.md §6.3).
|
||||
const moneyColor = (isProfit) => (isProfit ? STATUS.success : STATUS.error);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Sub-components
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Customer name + phone + location stacked cell. */
|
||||
function CustomerCell({ order }) {
|
||||
const name = order.customername ?? order.customer_name ?? order.deliverycustomer ?? order.pickupcustomer ?? 'Unknown customer';
|
||||
const phone = order.customerphone ?? order.phone ?? order.mobile ?? '';
|
||||
const location = order.locationname ?? order.kitchenname ?? order.kitchenName ?? '';
|
||||
|
||||
return (
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{phone && (
|
||||
<Text size="sm" type="supporting">
|
||||
{phone}
|
||||
</Text>
|
||||
)}
|
||||
{location && (
|
||||
<HStack gap={0.5} vAlign="center">
|
||||
<Center isInline width={12} height={12} style={{ color: 'var(--color-icon-secondary)' }}>
|
||||
<MdLocationOn size={12} />
|
||||
</Center>
|
||||
<Text size="sm" type="supporting" maxLines={1}>
|
||||
{location}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<TableScroll minWidth={720} maxHeight={360}>
|
||||
<Table density="compact" dividers="rows" hasHover>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Customer</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Planned KMs</TableHeaderCell>
|
||||
<TableHeaderCell>Actual KMs</TableHeaderCell>
|
||||
<TableHeaderCell>Trip KMs</TableHeaderCell>
|
||||
<TableHeaderCell>Revenue</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((order, idx) => (
|
||||
<TableRow key={`${order.orderid ?? 'order'}-${idx}`}>
|
||||
<TableCell>
|
||||
<CustomerCell order={order} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={order.orderstatus ?? order.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text hasTabularNumbers>{formatDistance(parseFloat(order.kms ?? 0))}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text hasTabularNumbers>{formatDistance(parseFloat(order.actualkms ?? 0))}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text hasTabularNumbers>{formatDistance(parseFloat(order.riderkms ?? 0))}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="bold" hasTabularNumbers style={{ textAlign: 'right' }}>
|
||||
{rupees(getRevenue(order))}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableScroll>
|
||||
);
|
||||
}
|
||||
|
||||
/** One cost tile inside the expanded panel. */
|
||||
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 }}
|
||||
>
|
||||
{icon}
|
||||
</Center>
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text size="sm" type="supporting">
|
||||
{label}
|
||||
</Text>
|
||||
<Text weight="bold" hasTabularNumbers style={{ color }}>
|
||||
{amount}
|
||||
</Text>
|
||||
<Text size="sm" type="supporting">
|
||||
{footnote}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Expanded cost breakdown + orders for one rider. */
|
||||
function RiderDetailPanel({ metrics }) {
|
||||
const { varCost, fixedCost, kms, net, margin } = metrics;
|
||||
const isProfit = net >= 0;
|
||||
const orderCount = metrics.orders?.length ?? 0;
|
||||
|
||||
return (
|
||||
<VStack gap={3} padding={3}>
|
||||
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
|
||||
<CostTile
|
||||
icon={<MdPayments size={16} />}
|
||||
color={STATUS.info}
|
||||
label="Fixed cost"
|
||||
amount={rupees(fixedCost, 2)}
|
||||
footnote="Salary (per slot)"
|
||||
/>
|
||||
<CostTile
|
||||
icon={<MdRoute size={16} />}
|
||||
color={STATUS.picked}
|
||||
label="Variable cost"
|
||||
amount={rupees(varCost, 2)}
|
||||
footnote={`${kms.toFixed(1)} km × ₹${VARIABLE_RATE_KM}/km`}
|
||||
/>
|
||||
<CostTile
|
||||
icon={isProfit ? <MdTrendingUp size={16} /> : <MdTrendingDown size={16} />}
|
||||
color={moneyColor(isProfit)}
|
||||
label={isProfit ? 'Net profit' : 'Net loss'}
|
||||
amount={`${isProfit ? '+' : ''}${rupees(net, 2)}`}
|
||||
footnote={`${Math.abs(margin).toFixed(0)}% margin`}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<VStack gap={2} padding={0}>
|
||||
<HStack justify="between" vAlign="center">
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Center isInline width={14} height={14} style={{ color: 'var(--color-icon-secondary)' }}>
|
||||
<MdReceipt size={14} />
|
||||
</Center>
|
||||
<Text weight="bold">Revenue breakdown</Text>
|
||||
</HStack>
|
||||
<Text size="sm" type="supporting">
|
||||
{orderCount} order{orderCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</HStack>
|
||||
<OrdersBreakdownTable orders={metrics.orders} getRevenue={orderRevenue} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single expandable rider profitability row. */
|
||||
function RiderProfitabilityCard({ rider, metrics, isExpanded, isFocused, onToggle, onFocus }) {
|
||||
const { revenue, kms, totalCost, net, margin, orders } = metrics;
|
||||
const isProfit = net >= 0;
|
||||
const orderCount = orders?.length ?? 0;
|
||||
const name = rider.riderName ?? rider.username ?? `Rider #${rider.id}`;
|
||||
|
||||
const handleOpenChange = () => {
|
||||
onToggle(rider.id);
|
||||
if (!isFocused && onFocus) onFocus(rider);
|
||||
};
|
||||
|
||||
// The resting frame matches every other table in the app. The focused state
|
||||
// keeps its accent outline — that is a selection affordance, not decoration,
|
||||
// and the brief was explicit about leaving selection alone.
|
||||
return (
|
||||
<Card
|
||||
padding={0}
|
||||
elevation="none"
|
||||
style={{ border: TABLE_FRAME, ...(isFocused ? { outline: '2px solid var(--color-accent)' } : null) }}
|
||||
>
|
||||
<Collapsible
|
||||
isOpen={isExpanded}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={
|
||||
<HStack gap={3} vAlign="center" wrap="wrap" padding={3}>
|
||||
<AccentAvatar color={moneyColor(isProfit)} selected size={36}>
|
||||
<Text size="sm" weight="bold" style={{ color: 'inherit' }}>
|
||||
{riderInitials(name)}
|
||||
</Text>
|
||||
</AccentAvatar>
|
||||
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="sm" type="supporting">
|
||||
{orderCount} order{orderCount !== 1 ? 's' : ''} · {kms.toFixed(1)} km total
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack gap={4} vAlign="center" wrap="wrap" style={{ marginInlineStart: 'auto' }}>
|
||||
<VStack gap={0} padding={0} hAlign="end">
|
||||
<Text size="sm" type="supporting">
|
||||
Revenue
|
||||
</Text>
|
||||
<Text weight="bold" hasTabularNumbers>
|
||||
{rupees(revenue)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} padding={0} hAlign="end">
|
||||
<Text size="sm" type="supporting">
|
||||
Cost
|
||||
</Text>
|
||||
<Text weight="bold" hasTabularNumbers>
|
||||
{rupees(totalCost)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0.5} padding={0} hAlign="end" width={120}>
|
||||
<Text size="sm" type="supporting">
|
||||
Net
|
||||
</Text>
|
||||
<Text weight="bold" hasTabularNumbers style={{ color: moneyColor(isProfit) }}>
|
||||
{isProfit ? '+' : ''}
|
||||
{rupees(net)}
|
||||
</Text>
|
||||
{/* Margin bar — a plain magnitude readout, so the value is the
|
||||
absolute margin clamped to 100 exactly as the old CSS bar was. */}
|
||||
<ProgressBar
|
||||
value={clamp(Math.abs(margin), 0, 100)}
|
||||
max={100}
|
||||
variant={isProfit ? 'success' : 'error'}
|
||||
label={`${Math.abs(margin).toFixed(0)}% margin`}
|
||||
isLabelHidden
|
||||
/>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<RiderDetailPanel metrics={metrics} />
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Main export
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ProfitabilitySection
|
||||
*
|
||||
* Props:
|
||||
* riders — Array of rider objects with .orders[]
|
||||
* totalDailyProfit — Number: daily aggregate across all slots
|
||||
* focusedRider — Rider object (or null) synced with map
|
||||
* handleRiderFocus — (rider) => void called when a card is clicked
|
||||
*/
|
||||
export default function ProfitabilitySection({
|
||||
riders = [],
|
||||
totalDailyProfit = 0,
|
||||
focusedRider = null,
|
||||
handleRiderFocus,
|
||||
selectedDate,
|
||||
batches
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState({});
|
||||
|
||||
const toggleRider = useCallback((id) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}, []);
|
||||
|
||||
// Enrich riders with computed metrics
|
||||
const enriched = useMemo(
|
||||
() => riders.map((r) => ({ ...r, _m: calcRiderMetrics(r, selectedDate, batches) })),
|
||||
[riders, selectedDate, batches]
|
||||
);
|
||||
|
||||
// Filter out inactive riders (those with 0 orders in the selected date/slot) to ensure accurate KPI aggregations
|
||||
const activeEnriched = useMemo(() => enriched.filter((r) => r._m.orders.length > 0), [enriched]);
|
||||
|
||||
// Aggregate overall slot totals (pre-filtered for BI consistency)
|
||||
const slotRevenue = activeEnriched.reduce((s, r) => s + r._m.revenue, 0);
|
||||
const slotCost = activeEnriched.reduce((s, r) => s + r._m.totalCost, 0);
|
||||
const slotNet = activeEnriched.reduce((s, r) => s + r._m.net, 0);
|
||||
const slotKms = activeEnriched.reduce((s, r) => s + r._m.kms, 0);
|
||||
const profitCount = activeEnriched.filter((r) => r._m.net >= 0).length;
|
||||
const lossCount = activeEnriched.length - profitCount;
|
||||
|
||||
const dailyIsProfit = totalDailyProfit >= 0;
|
||||
const slotIsProfit = slotNet >= 0;
|
||||
|
||||
// Sorted loss-first so the riders needing attention are at the top.
|
||||
const sorted = useMemo(() => [...activeEnriched].sort((a, b) => a._m.net - b._m.net), [activeEnriched]);
|
||||
|
||||
return (
|
||||
<VStack gap={3} padding={3}>
|
||||
<HStack justify="between" vAlign="center" wrap="wrap" gap={2}>
|
||||
<VStack gap={0.5} padding={0}>
|
||||
<Heading level={2}>Profitability overview</Heading>
|
||||
<Text type="supporting">
|
||||
{activeEnriched.length} rider{activeEnriched.length !== 1 ? 's' : ''} · {profitCount} profitable · {lossCount} at loss
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<KpiGrid minWidth={200}>
|
||||
<StatCard
|
||||
title={`Daily ${dailyIsProfit ? 'profit' : 'loss'}`}
|
||||
value={rupees(Math.abs(totalDailyProfit))}
|
||||
icon={dailyIsProfit ? <MdTrendingUp size={20} /> : <MdTrendingDown size={20} />}
|
||||
color={moneyColor(dailyIsProfit)}
|
||||
/>
|
||||
<StatCard
|
||||
title="Riders active"
|
||||
value={activeEnriched.length}
|
||||
icon={<MdPeopleAlt size={20} />}
|
||||
color={STATUS.info}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total distance"
|
||||
value={`${slotKms.toFixed(1)} km`}
|
||||
icon={<MdStraighten size={20} />}
|
||||
color={STATUS.picked}
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot revenue"
|
||||
value={rupees(slotRevenue)}
|
||||
icon={<MdAccountBalanceWallet size={20} />}
|
||||
color={STATUS.success}
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot cost"
|
||||
value={rupees(slotCost)}
|
||||
icon={<MdPayments size={20} />}
|
||||
color={STATUS.error}
|
||||
/>
|
||||
<StatCard
|
||||
title="Slot net"
|
||||
value={`${slotIsProfit ? '+' : ''}${rupees(slotNet)}`}
|
||||
icon={<MdOutlineSavings size={20} />}
|
||||
color={moneyColor(slotIsProfit)}
|
||||
/>
|
||||
</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} />} />
|
||||
) : (
|
||||
<VStack gap={2} padding={0}>
|
||||
{sorted.map((rider) => (
|
||||
<RiderProfitabilityCard
|
||||
key={rider.id}
|
||||
rider={rider}
|
||||
metrics={rider._m}
|
||||
isExpanded={!!expanded[rider.id]}
|
||||
isFocused={focusedRider?.id === rider.id}
|
||||
onToggle={toggleRider}
|
||||
onFocus={handleRiderFocus}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
CustomerCell.propTypes = {
|
||||
order: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
OrdersBreakdownTable.propTypes = {
|
||||
orders: PropTypes.array,
|
||||
getRevenue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
CostTile.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
color: PropTypes.string,
|
||||
label: PropTypes.node,
|
||||
amount: PropTypes.node,
|
||||
footnote: PropTypes.node
|
||||
};
|
||||
|
||||
RiderDetailPanel.propTypes = {
|
||||
metrics: PropTypes.shape({
|
||||
varCost: PropTypes.number.isRequired,
|
||||
fixedCost: PropTypes.number.isRequired,
|
||||
kms: PropTypes.number.isRequired,
|
||||
net: PropTypes.number.isRequired,
|
||||
margin: PropTypes.number.isRequired,
|
||||
orders: PropTypes.array
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
RiderProfitabilityCard.propTypes = {
|
||||
rider: PropTypes.object.isRequired,
|
||||
metrics: PropTypes.shape({
|
||||
revenue: PropTypes.number.isRequired,
|
||||
kms: PropTypes.number.isRequired,
|
||||
totalCost: PropTypes.number.isRequired,
|
||||
net: PropTypes.number.isRequired,
|
||||
margin: PropTypes.number.isRequired,
|
||||
orders: PropTypes.array
|
||||
}).isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
isFocused: PropTypes.bool,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
onFocus: PropTypes.func
|
||||
};
|
||||
|
||||
ProfitabilitySection.propTypes = {
|
||||
riders: PropTypes.array,
|
||||
totalDailyProfit: PropTypes.number,
|
||||
focusedRider: PropTypes.object,
|
||||
handleRiderFocus: PropTypes.func,
|
||||
selectedDate: PropTypes.string,
|
||||
batches: PropTypes.array
|
||||
};
|
||||
412
src/pages/doormile/dispatch/dispatchShared.js
Normal file
412
src/pages/doormile/dispatch/dispatchShared.js
Normal file
@@ -0,0 +1,412 @@
|
||||
// Shared constants and pure helpers for the Dispatch page and its
|
||||
// extracted sub-components (CompareDataPanel, etc.). Lives outside
|
||||
// Dispatch.js so we don't create a circular import between the host
|
||||
// component and the child views.
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { getStatusMeta } from 'themes/dt/status';
|
||||
|
||||
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
|
||||
// last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
|
||||
// breakpoint is 900px and `down()` is exclusive, so this is the identical
|
||||
// `max-width: 899.95px` query it generated — the same width the scoped media
|
||||
// blocks in Dispatch.css already key off.
|
||||
const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
|
||||
|
||||
export function useIsNarrowViewport() {
|
||||
const [isNarrow, setIsNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
||||
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
|
||||
const onChange = (e) => setIsNarrow(e.matches);
|
||||
// Re-sync on mount in case the viewport changed between the lazy initial
|
||||
// state and the effect running.
|
||||
setIsNarrow(mql.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isNarrow;
|
||||
}
|
||||
|
||||
// Enter/Space activation for div/li surfaces that carry an onClick.
|
||||
//
|
||||
// The dispatch UI is built from clickable cards and list rows that wrap
|
||||
// headings, badges and bars, so they can't become real <button>s (interactive
|
||||
// content nested in a button is invalid markup, and it would fight the card
|
||||
// CSS). `role="button"` + `tabIndex={0}` + this handler is the sanctioned
|
||||
// shape. Pair all three — a role without a tabIndex is not reachable, and a
|
||||
// click without a key handler is mouse-only.
|
||||
export const onActivate = (handler) => (e) => {
|
||||
if (!handler) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Status colour for the places that need a RAW HEX rather than a rendered
|
||||
// badge — Leaflet divIcon HTML built as a template string, and the `style`
|
||||
// attribute on a few inline pills. Anything that renders normal JSX should use
|
||||
// <StatusBadge status={...}/> instead; this exists only because you cannot put
|
||||
// a React component inside an `L.divIcon({ html: '...' })` string.
|
||||
//
|
||||
// This used to be a private STATUS_STYLES table, and it had drifted badly from
|
||||
// the canonical one in themes/dt/status.js:
|
||||
//
|
||||
// picked #0ea5e9 → #8b5cf6 (was identical to `active`, so two
|
||||
// active #0ea5e9 → #14b8a6 different states looked the same)
|
||||
// accepted #8b5cf6 → #6366f1 (was identical to the new `picked`)
|
||||
// arrived #ea580c → #06b6d4
|
||||
// delivered #22c55e → #10b981
|
||||
// skipped #94a3b8 → #f97316 (was the same grey as "unknown")
|
||||
//
|
||||
// So the dispatch map painted a picked-up order and an in-transit order the
|
||||
// same blue, while the deliveries table painted them purple and teal. Now both
|
||||
// read from one table. `getStatusMeta` also resolves the raw backend enums
|
||||
// (miler_assigned, converted_to_consignment, …), which this never did — those
|
||||
// previously fell through to the grey "unknown" branch.
|
||||
//
|
||||
// The `{ label, bg, fg }` shape is kept so the existing call sites are
|
||||
// unchanged; `fg` is always white, as it was in every entry of the old table.
|
||||
export const getStatusStyle = (status) => {
|
||||
const meta = getStatusMeta(status);
|
||||
return { label: meta.label, bg: meta.color, fg: '#fff' };
|
||||
};
|
||||
|
||||
// Order-status sets used for completion / skipped decisions across the
|
||||
// rider list, the planned-route renderer, and the compare data panel.
|
||||
export const FINAL_STATUSES = new Set(['delivered']);
|
||||
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
// Per-step palette — wider and more deliberately spaced than the rider
|
||||
// palette so a 10-stop day reads as 10 distinct colors on the compare
|
||||
// map's polylines + pins.
|
||||
export const STEP_PALETTE = [
|
||||
'#2563eb', // blue-600
|
||||
'#dc2626', // red-600
|
||||
'#16a34a', // green-600
|
||||
'#ea580c', // orange-600
|
||||
'#9333ea', // purple-600
|
||||
'#0891b2', // cyan-600
|
||||
'#ca8a04', // yellow-600
|
||||
'#db2777', // pink-600
|
||||
'#0f766e', // teal-700
|
||||
'#7c3aed', // violet-600
|
||||
'#65a30d', // lime-600
|
||||
'#0284c7', // sky-600
|
||||
'#b91c1c', // red-700
|
||||
'#15803d', // green-700
|
||||
'#a16207', // yellow-700
|
||||
'#86198f' // fuchsia-800
|
||||
];
|
||||
|
||||
export const stepColor = (i) =>
|
||||
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
|
||||
|
||||
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
|
||||
// by the compare data panel for the route-sequence diff list ("Visited
|
||||
// 4th · planned 2nd").
|
||||
export const ordinal = (n) => {
|
||||
if (n == null) return '';
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
|
||||
// An order is "active" (currently in progress) when it's neither completed
|
||||
// (delivered) nor skipped/cancelled. The Active view uses this to collapse a
|
||||
// rider down to the single delivery they're working on right now.
|
||||
export const isActiveDelivery = (o) => {
|
||||
const s = String(o?.orderstatus || '').toLowerCase();
|
||||
return s === 'active';
|
||||
};
|
||||
|
||||
// A rider's single in-progress delivery: the first non-final, non-skipped
|
||||
// stop in (trip, step) order. Returns null when the rider has nothing active
|
||||
// (everything delivered/cancelled, or GPS-only with no orders).
|
||||
export const getActiveOrder = (orders) => {
|
||||
if (!Array.isArray(orders) || !orders.length) return null;
|
||||
const sorted = [...orders].sort((a, b) => {
|
||||
const tA = a.trip_number || 1;
|
||||
const tB = b.trip_number || 1;
|
||||
if (tA !== tB) return tA - tB;
|
||||
return (a.step || 0) - (b.step || 0);
|
||||
});
|
||||
return sorted.find(isActiveDelivery) || null;
|
||||
};
|
||||
|
||||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
||||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
||||
// polyline so the Compare delta panel can show "actual km" without depending
|
||||
// on the backend's actualkms field (which can be stale or missing). Also
|
||||
// reused by deliveries.js's Update Status dialog to compute a real Actual
|
||||
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
|
||||
export function haversineKm(a, b) {
|
||||
const R = 6371; // km
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const lat1 = toRad(a[0]);
|
||||
const lat2 = toRad(b[0]);
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLon = toRad(b[1] - a[1]);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export function polylineLengthKm(points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += haversineKm(points[i - 1], points[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
|
||||
//
|
||||
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
|
||||
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
|
||||
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
|
||||
// velocity dynamics with random acceleration as process noise; measurement
|
||||
// model H = [1, 0] (we measure position only).
|
||||
//
|
||||
// Pipeline:
|
||||
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
|
||||
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
|
||||
// forward filter even with the in-loop Mahalanobis gate enabled.
|
||||
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
|
||||
// innovation exceeds the gate are not used to update; the prediction
|
||||
// is kept as the posterior. Stores prior + posterior moments at each
|
||||
// step so the backward pass can run.
|
||||
// 3. Backward RTS smoother — refines every step using ALL future
|
||||
// observations. Logs are fetched in one shot (not streamed) so we
|
||||
// can afford the second pass; the accuracy lift is biggest near the
|
||||
// start of the trail and through turns the forward pass under-corrects.
|
||||
//
|
||||
// Tuning (all in degrees² since pings are in lat/lng):
|
||||
// processNoise (q) — random-acceleration variance (deg²/s²). Default
|
||||
// tuned for urban two-wheelers (~1 m/s² accel).
|
||||
// Lower = smoother but slower to follow sharp turns.
|
||||
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
|
||||
// which matches consumer GPS in open urban areas.
|
||||
// Bump for dense canyons.
|
||||
// outlierGate — Mahalanobis² threshold for in-loop rejection.
|
||||
// 9.0 = 3σ (≈ 99.7% of inliers pass).
|
||||
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
|
||||
// 120 km/h covers any legal two-wheeler movement
|
||||
// plus margin; anything above is GPS error.
|
||||
export function kalmanSmoothGps(pings, options = {}) {
|
||||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||||
|
||||
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
||||
const cleanedPings = pings.filter(p =>
|
||||
Number.isFinite(p.lat) &&
|
||||
Number.isFinite(p.lng) &&
|
||||
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
|
||||
);
|
||||
|
||||
if (cleanedPings.length === 0) return [];
|
||||
if (cleanedPings.length === 1) {
|
||||
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
|
||||
}
|
||||
|
||||
const processNoise =
|
||||
options.processNoise != null ? options.processNoise : 1e-10;
|
||||
const measurementNoise =
|
||||
options.measurementNoise != null ? options.measurementNoise : 2e-9;
|
||||
const outlierGate =
|
||||
options.outlierGate != null ? options.outlierGate : 9.0;
|
||||
const maxSpeedKmh =
|
||||
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
|
||||
|
||||
const tsOf = (p) =>
|
||||
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
|
||||
|
||||
// 2. Scan forward to find the first valid starting anchor
|
||||
let startIdx = 0;
|
||||
while (startIdx < cleanedPings.length - 1) {
|
||||
const p0 = cleanedPings[startIdx];
|
||||
const p1 = cleanedPings[startIdx + 1];
|
||||
const ts0 = tsOf(p0);
|
||||
const ts1 = tsOf(p1) || ts0 + 1000;
|
||||
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
|
||||
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
|
||||
if (speedKmh <= maxSpeedKmh) {
|
||||
break;
|
||||
} else {
|
||||
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
||||
if (startIdx + 2 < cleanedPings.length) {
|
||||
const p2 = cleanedPings[startIdx + 2];
|
||||
const ts2 = tsOf(p2) || ts1 + 1000;
|
||||
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
|
||||
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
|
||||
const speedKmh12 = (km12 / dtSec12) * 3600;
|
||||
|
||||
if (speedKmh12 <= maxSpeedKmh) {
|
||||
startIdx = startIdx + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
startIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teleport filter starting from the valid anchor
|
||||
const accepted = [cleanedPings[startIdx]];
|
||||
let lastTs = tsOf(cleanedPings[startIdx]);
|
||||
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
|
||||
const p = cleanedPings[i];
|
||||
const ts = tsOf(p) || lastTs + 1000;
|
||||
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
|
||||
const prev = accepted[accepted.length - 1];
|
||||
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
if (speedKmh > maxSpeedKmh) continue;
|
||||
accepted.push(p);
|
||||
lastTs = ts;
|
||||
}
|
||||
|
||||
if (accepted.length < 2) {
|
||||
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
|
||||
}
|
||||
|
||||
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
|
||||
// positions parallel to `accepted`.
|
||||
const smoothAxis = (axisKey) => {
|
||||
const N = accepted.length;
|
||||
// Per-step storage for the backward RTS pass.
|
||||
const xPost = new Array(N); // [pos, vel] posterior after update
|
||||
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
|
||||
const xPrior = new Array(N); // predicted mean before update
|
||||
const pPrior = new Array(N); // predicted cov before update
|
||||
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
|
||||
|
||||
// Initial state: position = first measurement, velocity from the first
|
||||
// two pings (better than 0 — keeps the start of the trail from lagging
|
||||
// behind the rider's actual motion). Initial position covariance = r
|
||||
// (we just measured it); initial velocity covariance is loose so it
|
||||
// can be refined quickly.
|
||||
const ts0 = tsOf(accepted[0]);
|
||||
const ts1 = tsOf(accepted[1]);
|
||||
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
|
||||
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
|
||||
xPost[0] = [accepted[0][axisKey], v0];
|
||||
pPost[0] = [measurementNoise, 0, 0, 1];
|
||||
xPrior[0] = xPost[0].slice();
|
||||
pPrior[0] = pPost[0].slice();
|
||||
dtArr[0] = 0;
|
||||
|
||||
let prevTs = ts0;
|
||||
for (let i = 1; i < N; i++) {
|
||||
const ts = tsOf(accepted[i]) || prevTs + 1000;
|
||||
const dt = Math.max(0.1, (ts - prevTs) / 1000);
|
||||
prevTs = ts;
|
||||
dtArr[i] = dt;
|
||||
|
||||
// ─── Predict ───
|
||||
// x' = F x where F = [[1, dt], [0, 1]]
|
||||
const [xPrev, vPrev] = xPost[i - 1];
|
||||
const xPredPos = xPrev + vPrev * dt;
|
||||
const xPredVel = vPrev;
|
||||
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
|
||||
const dt2 = dt * dt;
|
||||
const dt3 = dt2 * dt;
|
||||
const dt4 = dt3 * dt;
|
||||
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
|
||||
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np11 = pp11 + dt2 * processNoise;
|
||||
xPrior[i] = [xPredPos, xPredVel];
|
||||
pPrior[i] = [np00, np01, np10, np11];
|
||||
|
||||
// ─── Update (with Mahalanobis gating) ───
|
||||
// y = z − Hx' (innovation)
|
||||
// S = H P' H^T + R (innovation covariance)
|
||||
// Reject the measurement if mahal² = y²/S exceeds the gate. The
|
||||
// prediction then carries forward as the posterior — the trail stays
|
||||
// continuous instead of being yanked toward a bad fix.
|
||||
const z = accepted[i][axisKey];
|
||||
const y = z - xPredPos;
|
||||
const S = np00 + measurementNoise;
|
||||
const mahal2 = (y * y) / S;
|
||||
if (mahal2 > outlierGate) {
|
||||
xPost[i] = [xPredPos, xPredVel];
|
||||
pPost[i] = [np00, np01, np10, np11];
|
||||
continue;
|
||||
}
|
||||
// K = P' H^T / S
|
||||
const K0 = np00 / S;
|
||||
const K1 = np10 / S;
|
||||
// x = x' + K y
|
||||
const newPos = xPredPos + K0 * y;
|
||||
const newVel = xPredVel + K1 * y;
|
||||
// P = (I − K H) P'
|
||||
xPost[i] = [newPos, newVel];
|
||||
pPost[i] = [
|
||||
(1 - K0) * np00,
|
||||
(1 - K0) * np01,
|
||||
np10 - K1 * np00,
|
||||
np11 - K1 * np01
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Backward RTS smoother ─────────────────────────────────────────
|
||||
// x_smooth[N-1] = x_post[N-1]
|
||||
// For i = N-2 … 0:
|
||||
// C = P_post[i] · F^T · inv(P_prior[i+1])
|
||||
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
|
||||
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
|
||||
// P_post · F^T = [[p00 + dt·p01, p01],
|
||||
// [p10 + dt·p11, p11]]
|
||||
const xSmooth = new Array(N);
|
||||
xSmooth[N - 1] = xPost[N - 1].slice();
|
||||
for (let i = N - 2; i >= 0; i--) {
|
||||
const dt = dtArr[i + 1];
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i];
|
||||
const a = pp00 + dt * pp01;
|
||||
const b = pp01;
|
||||
const c = pp10 + dt * pp11;
|
||||
const d = pp11;
|
||||
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
|
||||
const [q00, q01, q10, q11] = pPrior[i + 1];
|
||||
const det = q00 * q11 - q01 * q10;
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
|
||||
xSmooth[i] = xPost[i].slice();
|
||||
continue;
|
||||
}
|
||||
const inv00 = q11 / det;
|
||||
const inv01 = -q01 / det;
|
||||
const inv10 = -q10 / det;
|
||||
const inv11 = q00 / det;
|
||||
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
|
||||
const c00 = a * inv00 + b * inv10;
|
||||
const c01 = a * inv01 + b * inv11;
|
||||
const c10 = c * inv00 + d * inv10;
|
||||
const c11 = c * inv01 + d * inv11;
|
||||
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
|
||||
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
|
||||
xSmooth[i] = [
|
||||
xPost[i][0] + c00 * dxPos + c01 * dxVel,
|
||||
xPost[i][1] + c10 * dxPos + c11 * dxVel
|
||||
];
|
||||
}
|
||||
|
||||
return xSmooth.map((s) => s[0]);
|
||||
};
|
||||
|
||||
const lats = smoothAxis('lat');
|
||||
const lngs = smoothAxis('lng');
|
||||
return accepted.map((p, i) => ({
|
||||
lat: lats[i],
|
||||
lng: lngs[i],
|
||||
logdate: p.logdate,
|
||||
_ts: p._ts
|
||||
}));
|
||||
}
|
||||
434
src/pages/doormile/exceptions/Exceptions.jsx
Normal file
434
src/pages/doormile/exceptions/Exceptions.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, ClipboardList, Flame, Plus } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Field, Grid, Input, KpiCard, Modal, PageHeader,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
Textarea,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Exceptions — the register of things that went wrong with a consignment.
|
||||
*
|
||||
* An exception is raised against a consignment, not a booking: it describes
|
||||
* something that happened to a parcel already in the network, which is why the
|
||||
* form asks for a consignment id rather than an order number.
|
||||
*/
|
||||
|
||||
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
|
||||
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||
|
||||
/** The only two states this endpoint will write. */
|
||||
const RESOLVE_STATUSES = ['Resolved', 'Closed'];
|
||||
|
||||
const SEVERITY_TONE = { low: 'neutral', medium: 'warning', high: 'destructive', critical: 'destructive' };
|
||||
|
||||
const isClosed = (row) => ['resolved', 'closed'].includes(String(row?.status || '').toLowerCase());
|
||||
|
||||
const EMPTY_FORM = {
|
||||
consignmentid: '',
|
||||
tripsheetid: '',
|
||||
hubid: '',
|
||||
exceptiontype: 'Damaged',
|
||||
severity: 'Medium',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const NO_HUB = 'none';
|
||||
|
||||
export default function Exceptions() {
|
||||
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
|
||||
const { data: hubs = [] } = useHubs();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [resolveRow, setResolveRow] = useState(null);
|
||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
const createException = useCreateException();
|
||||
const updateStatus = useUpdateExceptionStatus();
|
||||
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((hub) => [hub.hubid, hub])), [hubs]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = exceptions.length;
|
||||
return {
|
||||
total,
|
||||
open: exceptions.filter((row) => !isClosed(row)).length,
|
||||
critical: exceptions.filter((row) => String(row.severity || '').toLowerCase() === 'critical').length,
|
||||
resolved: exceptions.filter(isClosed).length,
|
||||
};
|
||||
}, [exceptions]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = exceptions;
|
||||
if (activeFilter === 'open') list = list.filter((row) => !isClosed(row));
|
||||
else if (activeFilter === 'critical') {
|
||||
list = list.filter((row) => String(row.severity || '').toLowerCase() === 'critical');
|
||||
} else if (activeFilter === 'resolved') list = list.filter(isClosed);
|
||||
|
||||
return list.filter((row) =>
|
||||
matchesQuery(row, ['consignmentid', 'exceptiontype', 'description', 'severity', 'status'], debouncedSearch)
|
||||
);
|
||||
}, [exceptions, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const logException = () => {
|
||||
const nextErrors = {};
|
||||
if (!String(form.consignmentid).trim()) nextErrors.consignmentid = 'Enter the consignment id';
|
||||
if (!form.description.trim()) nextErrors.description = 'Describe what happened';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createException.mutate(
|
||||
{
|
||||
consignmentid: form.consignmentid,
|
||||
/* Omitted rather than sent empty: the server treats a missing optional
|
||||
key as "not applicable" and an empty string as a bad value. */
|
||||
tripsheetid: form.tripsheetid || undefined,
|
||||
hubid: form.hubid ? Number(form.hubid) : undefined,
|
||||
exceptiontype: form.exceptiontype,
|
||||
severity: form.severity,
|
||||
description: form.description,
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setDialogOpen(false) }
|
||||
);
|
||||
};
|
||||
|
||||
const openResolve = (row) => {
|
||||
setResolveRow(row);
|
||||
setResolveStatus('Resolved');
|
||||
setResolution('');
|
||||
};
|
||||
|
||||
const submitResolve = () => {
|
||||
updateStatus.mutate(
|
||||
{ id: resolveRow.exceptionid ?? resolveRow.id, status: resolveStatus, resolution },
|
||||
{ onSuccess: (res) => res?.success !== false && setResolveRow(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'consignmentid',
|
||||
header: 'Consignment & hub',
|
||||
sortable: true,
|
||||
cell: (row) => {
|
||||
const hub = hubMap.get(row.hubid);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">#{orDash(row.consignmentid)}</p>
|
||||
<p className="truncate text-caption text-ink-3">
|
||||
{hub?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'exceptiontype',
|
||||
header: 'Exception',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">
|
||||
{String(row.exceptiontype || 'Exception').replace(/_/g, ' ')}
|
||||
</p>
|
||||
{row.description ? (
|
||||
<p className="truncate text-caption text-ink-3" title={row.description}>
|
||||
{row.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<StatusBadge
|
||||
status={row.severity}
|
||||
tone={SEVERITY_TONE[String(row.severity || '').toLowerCase()] || 'neutral'}
|
||||
dot
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status || 'Open'} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'createdat',
|
||||
header: 'Raised',
|
||||
sortable: true,
|
||||
accessor: (row) => row.createdat,
|
||||
cell: (row) => (
|
||||
<span className="text-body-sm text-ink-2">{formatDoormileTimestamp(row.createdat, 'DD MMM, hh:mm A')}</span>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) =>
|
||||
isClosed(row) ? (
|
||||
<span className="text-caption text-ink-4">Closed</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
|
||||
Resolve
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[hubMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={AlertTriangle}
|
||||
title="Exceptions"
|
||||
subtitle="Parcels that need a decision before they can move again"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Log exception
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={ClipboardList}
|
||||
value={stats.total}
|
||||
label="Total raised"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={AlertTriangle}
|
||||
value={stats.open}
|
||||
label={`Open · ${percentOf(stats.open, stats.total)}%`}
|
||||
tone="warning"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('open')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Flame}
|
||||
value={stats.critical}
|
||||
label="Critical severity"
|
||||
tone="navy"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('critical')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={stats.resolved}
|
||||
label={`Resolved · ${percentOf(stats.resolved, stats.total)}%`}
|
||||
tone="success"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('resolved')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by consignment, type or description"
|
||||
count={{ shown: rows.length, total: exceptions.length, noun: 'exceptions' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.exceptionid ?? row.id}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'createdat', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title="Nothing has gone wrong"
|
||||
description="No exceptions have been raised against a consignment."
|
||||
action={{ label: 'Log exception', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="Log an exception"
|
||||
description="Raised against a consignment — a parcel already moving through the network."
|
||||
icon={AlertTriangle}
|
||||
size="lg"
|
||||
busy={createException.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={logException} disabled={createException.isPending}>
|
||||
Log exception
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Consignment id" required error={errors.consignmentid}>
|
||||
<Input
|
||||
value={form.consignmentid}
|
||||
onChange={(e) => set('consignmentid')(e.target.value)}
|
||||
placeholder="40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Tripsheet id" hint="Only if the parcel was on a hub-to-hub trip">
|
||||
<Input value={form.tripsheetid} onChange={(e) => set('tripsheetid')(e.target.value)} placeholder="Optional" />
|
||||
</Field>
|
||||
|
||||
<Field label="Exception type">
|
||||
<Select value={form.exceptiontype} onValueChange={set('exceptiontype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXCEPTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Severity">
|
||||
<Select value={form.severity} onValueChange={set('severity')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.map((severity) => (
|
||||
<SelectItem key={severity} value={severity}>
|
||||
{severity}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Hub" className="sm:col-span-2">
|
||||
<Select
|
||||
value={form.hubid === '' ? NO_HUB : String(form.hubid)}
|
||||
onValueChange={(value) => set('hubid')(value === NO_HUB ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not at a hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_HUB}>Not at a hub</SelectItem>
|
||||
{(hubs || []).map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="What happened" required error={errors.description} className="sm:col-span-2">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={form.description}
|
||||
onChange={(e) => set('description')(e.target.value)}
|
||||
placeholder="Outer box crushed on arrival at the sorting centre; contents appear intact."
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(resolveRow)}
|
||||
onOpenChange={(open) => !open && setResolveRow(null)}
|
||||
title="Close this exception"
|
||||
description={`Consignment #${resolveRow?.consignmentid ?? ''} — ${String(resolveRow?.exceptiontype || '').replace(/_/g, ' ')}`}
|
||||
icon={CheckCircle2}
|
||||
busy={updateStatus.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setResolveRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitResolve} disabled={updateStatus.isPending}>
|
||||
Update exception
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
<Field label="Outcome">
|
||||
<Select value={resolveStatus} onValueChange={setResolveStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESOLVE_STATUSES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Resolution" hint="What was done, and who it was agreed with">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
placeholder="Repacked and re-dispatched on tripsheet 118; customer notified."
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
388
src/pages/doormile/hubs/Hubs.jsx
Normal file
388
src/pages/doormile/hubs/Hubs.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { CheckCircle2, MapPin, Pencil, Plus, Trash2, Warehouse } from 'lucide-react';
|
||||
import {
|
||||
Button, ConfirmModal, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard,
|
||||
Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useCreateHub, useDeleteHub, useHubs, useUpdateHub } from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Hubs — the sorting centres and delivery hubs the network runs through.
|
||||
*
|
||||
* A hub is also the console's stand-in for a zone: the API has no zones
|
||||
* resource, and `applocationid` exists only as a field here, which is why the
|
||||
* city picker on every other page is derived from this list.
|
||||
*/
|
||||
|
||||
const HUB_TYPE_OPTIONS = [
|
||||
{ value: 'delivery_hub', label: 'Delivery Hub' },
|
||||
{ value: 'sorting_center', label: 'Sorting Center' },
|
||||
];
|
||||
|
||||
const hubTypeLabel = (value) =>
|
||||
HUB_TYPE_OPTIONS.find((option) => option.value === value)?.label || orDash(value);
|
||||
|
||||
/* POST /admin/hubs rejects a lower-cased status — "active" fails where "Active"
|
||||
works — and Inactive was never confirmed to be accepted on create. Both are
|
||||
offered because the edit path needs to be able to stand a hub down; the
|
||||
capitalisation is not optional. */
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
hubid: null,
|
||||
hubname: '',
|
||||
hubtype: 'delivery_hub',
|
||||
applocationid: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
pincode: '',
|
||||
status: 'Active',
|
||||
};
|
||||
|
||||
export default function Hubs() {
|
||||
const { data: hubs = [], isLoading, isFetching } = useHubs();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const createHub = useCreateHub();
|
||||
const updateHub = useUpdateHub();
|
||||
const deleteHub = useDeleteHub();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = hubs.length;
|
||||
return {
|
||||
total,
|
||||
sorting: hubs.filter((hub) => hub.hubtype === 'sorting_center').length,
|
||||
delivery: hubs.filter((hub) => hub.hubtype === 'delivery_hub').length,
|
||||
active: hubs.filter((hub) => String(hub.status || '').toLowerCase() === 'active').length,
|
||||
};
|
||||
}, [hubs]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = hubs;
|
||||
if (activeFilter === 'sorting_center') list = list.filter((hub) => hub.hubtype === 'sorting_center');
|
||||
else if (activeFilter === 'delivery_hub') list = list.filter((hub) => hub.hubtype === 'delivery_hub');
|
||||
else if (activeFilter === 'active') {
|
||||
list = list.filter((hub) => String(hub.status || '').toLowerCase() === 'active');
|
||||
}
|
||||
return list.filter((hub) => matchesQuery(hub, ['hubname', 'hubtype', 'address', 'pincode'], debouncedSearch));
|
||||
}, [hubs, activeFilter, debouncedSearch]);
|
||||
|
||||
/* Clicking a KPI card that is already the active filter clears it, so the
|
||||
cards read as a set of toggles rather than a one-way trip that needs a
|
||||
separate "show everything" control. */
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
hubid: row.hubid,
|
||||
hubname: row.hubname || '',
|
||||
hubtype: row.hubtype || 'delivery_hub',
|
||||
applocationid: row.applocationid ?? '',
|
||||
contactno: row.contactno || '',
|
||||
address: row.address || '',
|
||||
latitude: row.latitude ?? '',
|
||||
longitude: row.longitude ?? '',
|
||||
pincode: row.pincode || '',
|
||||
status: row.status || 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.hubname.trim()) nextErrors.hubname = 'Give the hub a name';
|
||||
if (form.applocationid === '' || Number.isNaN(Number(form.applocationid))) {
|
||||
nextErrors.applocationid = 'Enter the numeric city id';
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const { hubid, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
applocationid: Number(rest.applocationid),
|
||||
/* Coordinates are optional in the form but numeric on the wire — an empty
|
||||
string here is rejected, whereas 0 is accepted and simply means the hub
|
||||
has not been pinned on the map yet. */
|
||||
latitude: Number(rest.latitude) || 0,
|
||||
longitude: Number(rest.longitude) || 0,
|
||||
};
|
||||
|
||||
const mutation = hubid ? updateHub : createHub;
|
||||
const variables = hubid ? { id: hubid, data } : data;
|
||||
mutation.mutate(variables, { onSuccess: (res) => res?.success !== false && setDialogOpen(false) });
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'hubname',
|
||||
header: 'Hub',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-brand-tint text-brand">
|
||||
<Warehouse className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.hubname)}</p>
|
||||
<p className="text-caption text-ink-3">{hubTypeLabel(row.hubtype)}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
header: 'Address & location',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
|
||||
{orDash(row.address)}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{[row.pincode, row.applocationid ? `City #${row.applocationid}` : null].filter(Boolean).join(' · ') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'contactno', header: 'Contact', accessor: (row) => orDash(row.contactno), hideBelow: 'md' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit hub" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton
|
||||
label="Delete hub"
|
||||
icon={Trash2}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete(row)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Warehouse}
|
||||
title="Hubs"
|
||||
subtitle="Sorting centres and delivery hubs across the network"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New hub
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={Warehouse}
|
||||
value={stats.total}
|
||||
label="Total hubs"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Warehouse}
|
||||
value={stats.sorting}
|
||||
label={`Sorting centres · ${percentOf(stats.sorting, stats.total)}%`}
|
||||
tone="navy"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('sorting_center')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={MapPin}
|
||||
value={stats.delivery}
|
||||
label={`Delivery hubs · ${percentOf(stats.delivery, stats.total)}%`}
|
||||
tone="accent"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('delivery_hub')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={stats.active}
|
||||
label={`Active · ${percentOf(stats.active, stats.total)}%`}
|
||||
tone="success"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('active')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search hubs, addresses or pincodes"
|
||||
count={{ shown: rows.length, total: hubs.length, noun: 'hubs' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.hubid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'hubname', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Warehouse}
|
||||
title="No hubs yet"
|
||||
description="Add the first sorting centre or delivery hub to start routing through the network."
|
||||
action={{ label: 'New hub', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={form.hubid ? 'Edit hub' : 'New hub'}
|
||||
description="Hubs double as the console's zones — the city id is what every zone picker reads."
|
||||
icon={Warehouse}
|
||||
size="lg"
|
||||
busy={createHub.isPending || updateHub.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createHub.isPending || updateHub.isPending}>
|
||||
{form.hubid ? 'Save changes' : 'Create hub'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Hub name" required error={errors.hubname} className="sm:col-span-2">
|
||||
<Input value={form.hubname} onChange={(e) => set('hubname')(e.target.value)} placeholder="Peelamedu Hub" />
|
||||
</Field>
|
||||
|
||||
<Field label="Hub type">
|
||||
<Select value={form.hubtype} onValueChange={set('hubtype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HUB_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="City id" required hint="The applocationid every zone picker groups on" error={errors.applocationid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.applocationid}
|
||||
onChange={(e) => set('applocationid')(e.target.value)}
|
||||
placeholder="1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Contact number">
|
||||
<Input value={form.contactno} onChange={(e) => set('contactno')(e.target.value)} placeholder="9876543210" />
|
||||
</Field>
|
||||
|
||||
<Field label="Address" className="sm:col-span-2">
|
||||
<Input value={form.address} onChange={(e) => set('address')(e.target.value)} placeholder="Street, area, landmark" />
|
||||
</Field>
|
||||
|
||||
<Field label="Pincode">
|
||||
<Input value={form.pincode} onChange={(e) => set('pincode')(e.target.value)} placeholder="641004" />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Latitude">
|
||||
<Input value={form.latitude} onChange={(e) => set('latitude')(e.target.value)} placeholder="11.0168" />
|
||||
</Field>
|
||||
<Field label="Longitude">
|
||||
<Input value={form.longitude} onChange={(e) => set('longitude')(e.target.value)} placeholder="76.9558" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete this hub?"
|
||||
description={`"${pendingDelete?.hubname || ''}" will be removed from the network. Orders already routed through it are not affected.`}
|
||||
confirmLabel="Delete hub"
|
||||
tone="destructive"
|
||||
busy={deleteHub.isPending}
|
||||
onConfirm={() => {
|
||||
deleteHub.mutate(pendingDelete.hubid, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
394
src/pages/doormile/orders/CreateOrder.jsx
Normal file
394
src/pages/doormile/orders/CreateOrder.jsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, MapPin, Package, Plus, Route, Trash2, User } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, IconButton, Input, PageHeader, SectionHeader,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, Surface,
|
||||
Textarea,
|
||||
} from '@/components/ds';
|
||||
import { useAppLocations, useCreateBooking, usePricing, useTenants } from '@/lib/doormileHooks';
|
||||
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
|
||||
import { currency } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Create a single order.
|
||||
*
|
||||
* `POST /admin/expressbooking` takes one flat booking: the pickup, the delivery
|
||||
* recipient (which the API models as "the customer"), a service option, a final
|
||||
* price, and a list of parcels.
|
||||
*
|
||||
* Two things about that payload are not obvious:
|
||||
*
|
||||
* - Raw pickup address fields are always sent, never a saved `pickuplocationid`
|
||||
* — that shortcut is confirmed to 500 server-side.
|
||||
* - A parcel has no quantity field. "Five items" is five parcel entries, which
|
||||
* is also how the Deliveries page reads the count back.
|
||||
*
|
||||
* The price is calculated the same way the booking flow does: routed driving
|
||||
* distance from OSRM (falling back to a straight line × 1.3 when the router is
|
||||
* unreachable), then the client's own base + per-km rate.
|
||||
*/
|
||||
|
||||
const SERVICE_OPTIONS = ['Normal', 'Express', 'Scheduled'];
|
||||
|
||||
const EMPTY_PARTY = {
|
||||
name: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
landmark: '',
|
||||
suburb: '',
|
||||
city: '',
|
||||
postcode: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
};
|
||||
|
||||
const EMPTY_PACKAGE = { category: '', weight: '', description: '', count: 1 };
|
||||
|
||||
export default function CreateOrder() {
|
||||
const navigate = useNavigate();
|
||||
const createBooking = useCreateBooking();
|
||||
|
||||
const loginTenantId = localStorage.getItem('tenantid');
|
||||
const isStaff = !loginTenantId || loginTenantId === '0';
|
||||
|
||||
const { data: tenants = [] } = useTenants({ enabled: isStaff });
|
||||
const { data: pricing = [] } = usePricing();
|
||||
useAppLocations();
|
||||
|
||||
const [tenantId, setTenantId] = useState(isStaff ? '' : loginTenantId);
|
||||
const [pickup, setPickup] = useState(EMPTY_PARTY);
|
||||
const [drop, setDrop] = useState(EMPTY_PARTY);
|
||||
const [packages, setPackages] = useState([EMPTY_PACKAGE]);
|
||||
const [serviceOption, setServiceOption] = useState('Normal');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [route, setRoute] = useState({ distance: 0, minutes: null, resolved: false });
|
||||
|
||||
/* The rate card for the chosen client. `tenantid` is numeric on a pricing row
|
||||
and a string everywhere it comes from, so both sides are compared as
|
||||
strings — a strict match here silently priced every order at zero. */
|
||||
const rate = useMemo(
|
||||
() => (pricing || []).find((row) => String(row.tenantid) === String(tenantId)),
|
||||
[pricing, tenantId]
|
||||
);
|
||||
|
||||
const totalCharge = useMemo(() => {
|
||||
if (!route.resolved || !rate) return 0;
|
||||
return calculateTotalCharge(
|
||||
route.distance,
|
||||
Number(rate.baseprice) || 0,
|
||||
Number(rate.priceperkm) || 0,
|
||||
Number(rate.basedistance) || 0
|
||||
);
|
||||
}, [route, rate]);
|
||||
|
||||
/* Recalculated whenever both ends have coordinates. Without them there is no
|
||||
honest distance to show, so the price stays blank rather than defaulting to
|
||||
the base fare. */
|
||||
useEffect(() => {
|
||||
const from = { latitude: Number(pickup.latitude), longitude: Number(pickup.longitude) };
|
||||
const to = { latitude: Number(drop.latitude), longitude: Number(drop.longitude) };
|
||||
if (!from.latitude || !from.longitude || !to.latitude || !to.longitude) {
|
||||
setRoute({ distance: 0, minutes: null, resolved: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
calculateDrivingDistance(from, to)
|
||||
.then((distance) => {
|
||||
if (cancelled) return;
|
||||
setRoute({ distance, minutes: getLastRouteDurationMin(), resolved: true });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRoute({ distance: 0, minutes: null, resolved: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pickup.latitude, pickup.longitude, drop.latitude, drop.longitude]);
|
||||
|
||||
const setParty = (setter) => (key) => (value) => setter((current) => ({ ...current, [key]: value }));
|
||||
const setPickupField = setParty(setPickup);
|
||||
const setDropField = setParty(setDrop);
|
||||
|
||||
const setPackage = (index, key, value) =>
|
||||
setPackages((current) => current.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
|
||||
|
||||
const validate = () => {
|
||||
const next = {};
|
||||
if (!tenantId) next.tenantId = 'Choose the client this order is for';
|
||||
if (!pickup.name.trim()) next.pickupName = 'Enter the pickup contact name';
|
||||
if (!/^\d{10}$/.test(pickup.contactno)) next.pickupContact = 'Enter a 10-digit pickup number';
|
||||
if (!pickup.address.trim()) next.pickupAddress = 'Enter the pickup address';
|
||||
if (!pickup.city.trim()) next.pickupCity = 'Enter the pickup city';
|
||||
if (!pickup.postcode.trim()) next.pickupPostcode = 'Enter the pickup pincode';
|
||||
if (!drop.name.trim()) next.dropName = 'Enter the delivery contact name';
|
||||
if (!/^\d{10}$/.test(drop.contactno)) next.dropContact = 'Enter a 10-digit delivery number';
|
||||
if (!drop.address.trim()) next.dropAddress = 'Enter the delivery address';
|
||||
if (!drop.city.trim()) next.dropCity = 'Enter the delivery city';
|
||||
if (!drop.postcode.trim()) next.dropPostcode = 'Enter the delivery pincode';
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const declaredValue = Number(totalCharge.toFixed(2)) || 0;
|
||||
|
||||
/* One entry per item: the parcel schema has no quantity field, so a
|
||||
package with a count of five is sent as five parcels. */
|
||||
const parcels = packages
|
||||
.filter((pkg) => pkg.category || pkg.description)
|
||||
.flatMap((pkg) =>
|
||||
Array.from({ length: Math.max(1, Number(pkg.count) || 1) }, () => ({
|
||||
itemcategory: pkg.category || 'General',
|
||||
itemdescription: [pkg.description, pkg.weight].filter(Boolean).join(' · ') || 'Order',
|
||||
declaredvalue: declaredValue,
|
||||
}))
|
||||
);
|
||||
|
||||
createBooking.mutate(
|
||||
{
|
||||
tenantid: Number(tenantId),
|
||||
pickupaddress: pickup.address,
|
||||
pickuppincode: pickup.postcode,
|
||||
pickupcity: pickup.city,
|
||||
pickuplatitude: Number(pickup.latitude) || 0,
|
||||
pickuplongitude: Number(pickup.longitude) || 0,
|
||||
customer_phone: drop.contactno,
|
||||
customer_name: drop.name,
|
||||
deliveryaddress: drop.address,
|
||||
deliverypincode: drop.postcode,
|
||||
deliverycity: drop.city,
|
||||
deliverylatitude: Number(drop.latitude) || 0,
|
||||
deliverylongitude: Number(drop.longitude) || 0,
|
||||
service_option: serviceOption,
|
||||
finalprice: declaredValue,
|
||||
notes,
|
||||
parcels: parcels.length
|
||||
? parcels
|
||||
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: declaredValue }],
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/orders') }
|
||||
);
|
||||
};
|
||||
|
||||
const partyFields = (label, party, setField, prefix) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label={`${label} contact name`} required error={errors[`${prefix}Name`]}>
|
||||
<Input value={party.name} onChange={(e) => setField('name')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={`${label} contact number`} required error={errors[`${prefix}Contact`]}>
|
||||
<Input
|
||||
value={party.contactno}
|
||||
onChange={(e) => setField('contactno')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Landmark">
|
||||
<Input value={party.landmark} onChange={(e) => setField('landmark')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Address" required error={errors[`${prefix}Address`]} className="lg:col-span-2">
|
||||
<Input value={party.address} onChange={(e) => setField('address')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Suburb / area">
|
||||
<Input value={party.suburb} onChange={(e) => setField('suburb')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="City" required error={errors[`${prefix}City`]}>
|
||||
<Input value={party.city} onChange={(e) => setField('city')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Pincode" required error={errors[`${prefix}Postcode`]}>
|
||||
<Input value={party.postcode} onChange={(e) => setField('postcode')(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Latitude">
|
||||
<Input value={party.latitude} onChange={(e) => setField('latitude')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Longitude">
|
||||
<Input value={party.longitude} onChange={(e) => setField('longitude')(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
title="New order"
|
||||
subtitle="A single pickup and delivery"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/orders">Orders</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>New order</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<Stack space="default">
|
||||
{isStaff && (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Client" subtitle="Whose order this is, and whose rate card prices it." className="mb-4" />
|
||||
<Field label="Client" required error={errors.tenantId} className="max-w-md">
|
||||
<Select value={String(tenantId)} onValueChange={setTenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Pickup" subtitle="Where the parcel is collected from." icon={MapPin} className="mb-4" />
|
||||
{partyFields('Pickup', pickup, setPickupField, 'pickup')}
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Delivery"
|
||||
subtitle="The recipient. This contact becomes the booking's customer."
|
||||
icon={User}
|
||||
className="mb-4"
|
||||
/>
|
||||
{partyFields('Delivery', drop, setDropField, 'drop')}
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Parcels"
|
||||
subtitle="A count of five sends five parcel entries — the API has no quantity field."
|
||||
icon={Package}
|
||||
actions={
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPackages((c) => [...c, EMPTY_PACKAGE])}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add package
|
||||
</Button>
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
{packages.map((pkg, index) => (
|
||||
<div key={index} className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_2fr_auto_auto]">
|
||||
<Field label={index === 0 ? 'Category' : undefined}>
|
||||
<Input value={pkg.category} onChange={(e) => setPackage(index, 'category', e.target.value)} placeholder="General" />
|
||||
</Field>
|
||||
<Field label={index === 0 ? 'Weight' : undefined}>
|
||||
<Input value={pkg.weight} onChange={(e) => setPackage(index, 'weight', e.target.value)} placeholder="2 kg" />
|
||||
</Field>
|
||||
<Field label={index === 0 ? 'Description' : undefined}>
|
||||
<Input
|
||||
value={pkg.description}
|
||||
onChange={(e) => setPackage(index, 'description', e.target.value)}
|
||||
placeholder="Sealed carton"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={index === 0 ? 'Count' : undefined}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pkg.count}
|
||||
onChange={(e) => setPackage(index, 'count', e.target.value)}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
<div className={index === 0 ? 'flex items-end pb-0.5' : 'flex items-start'}>
|
||||
<IconButton
|
||||
label="Remove package"
|
||||
icon={Trash2}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={packages.length === 1}
|
||||
onClick={() => setPackages((current) => current.filter((_, i) => i !== index))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Service & price" icon={Route} className="mb-4" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Service option">
|
||||
<Select value={serviceOption} onValueChange={setServiceOption}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERVICE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Notes for the rider" className="sm:col-span-2">
|
||||
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 rounded-xl bg-surface-sunken p-4">
|
||||
<div>
|
||||
<p className="text-caption uppercase tracking-wide text-ink-3">Distance</p>
|
||||
<p className="text-body font-semibold text-ink-1">
|
||||
{route.resolved
|
||||
? `${route.distance} km${route.minutes ? ` · ${route.minutes} min` : ''}`
|
||||
: 'Enter both sets of coordinates'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-caption uppercase tracking-wide text-ink-3">Charge</p>
|
||||
<p className="text-title font-semibold text-brand">
|
||||
{route.resolved && rate ? currency(totalCharge) : '—'}
|
||||
</p>
|
||||
{!rate && tenantId ? (
|
||||
<p className="text-caption text-warning">No pricing rule for this client yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/orders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createBooking.isPending}>
|
||||
Create order
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
353
src/pages/doormile/orders/MultipleOrders.jsx
Normal file
353
src/pages/doormile/orders/MultipleOrders.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, FileSpreadsheet, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, IconButton,
|
||||
PageHeader, SectionHeader, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Stack, Surface, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { useCreateBookingsBulk, useTenantLocations, useTenants } from '@/lib/doormileHooks';
|
||||
import { SHEET_HEADER_MAP, cleanReceiverName, normalizeHeader, requiredSheetColumns } from '@/lib/bulkOrderColumns';
|
||||
import { OpenToast } from '@/api/doormile/notify';
|
||||
import { exportRows, orDash } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Bulk order upload.
|
||||
*
|
||||
* One pickup — a branch of the client — and a spreadsheet of recipients. The
|
||||
* sheet's own column titles are mapped onto the payload's field names by
|
||||
* `SHEET_HEADER_MAP`, which is shared rather than redeclared so a sheet that
|
||||
* uploads here cannot fail somewhere else.
|
||||
*
|
||||
* Raw pickup fields are always sent, never a saved `pickuplocationid`: that
|
||||
* shortcut is confirmed to 500 server-side, on the bulk handler as much as the
|
||||
* single one. Note the branch record spells it `pincode`, not `postcode`.
|
||||
*/
|
||||
|
||||
const MAX_PER_CALL = 200;
|
||||
|
||||
export default function MultipleOrders() {
|
||||
const navigate = useNavigate();
|
||||
const fileRef = useRef(null);
|
||||
const createBulk = useCreateBookingsBulk();
|
||||
|
||||
const loginTenantId = localStorage.getItem('tenantid');
|
||||
const isStaff = !loginTenantId || loginTenantId === '0';
|
||||
|
||||
const [tenantId, setTenantId] = useState(isStaff ? '' : loginTenantId);
|
||||
const [locationId, setLocationId] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [rows, setRows] = useState([]);
|
||||
const [missingColumns, setMissingColumns] = useState([]);
|
||||
|
||||
const { data: tenants = [] } = useTenants({ enabled: isStaff });
|
||||
const { data: locations = [] } = useTenantLocations(tenantId || null);
|
||||
|
||||
const pickup = useMemo(
|
||||
() => (locations || []).find((entry) => String(entry.locationid ?? entry.tenantlocationid) === String(locationId)),
|
||||
[locations, locationId]
|
||||
);
|
||||
|
||||
const parseSheet = async (file) => {
|
||||
const XLSX = await import('xlsx');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
return XLSX.utils.sheet_to_json(sheet, { defval: '' });
|
||||
};
|
||||
|
||||
const onFile = async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
if (!/\.(csv|xls|xlsx)$/.test(name)) {
|
||||
OpenToast('Upload a CSV or Excel file', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await parseSheet(file);
|
||||
if (!data.length) {
|
||||
OpenToast('That file has no rows', 'warning');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Header titles are normalised before lookup — the same column arrives as
|
||||
"Receiver Name*", "receivername*" and "Receiver Name*" across tenants. */
|
||||
const normalisedMap = {};
|
||||
Object.entries(SHEET_HEADER_MAP).forEach(([key, value]) => {
|
||||
normalisedMap[normalizeHeader(key)] = value;
|
||||
});
|
||||
|
||||
const mapped = data.map((row, index) => {
|
||||
const next = { _rowkey: `row-${index}` };
|
||||
Object.entries(row).forEach(([key, value]) => {
|
||||
const field = normalisedMap[normalizeHeader(key)] || normalizeHeader(key);
|
||||
/* A receiver name often arrives carrying the sheet's own row
|
||||
numbering — "12. Ravi Kumar" — so stripping it is part of parsing,
|
||||
not a cosmetic tidy-up. */
|
||||
next[field] = field === 'firstname' ? cleanReceiverName(value) : value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
const headers = Object.keys(data[0]).map(normalizeHeader);
|
||||
setMissingColumns(requiredSheetColumns().filter((column) => !headers.includes(normalizeHeader(column))));
|
||||
setRows(mapped);
|
||||
setFileName(file.name);
|
||||
OpenToast(`${mapped.length} rows read`, 'success');
|
||||
} catch (err) {
|
||||
OpenToast(err?.message || 'Could not read that file', 'error', 4000);
|
||||
} finally {
|
||||
/* Cleared so re-selecting the same file fires a change event again. */
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () =>
|
||||
exportRows(
|
||||
[Object.fromEntries(Object.keys(SHEET_HEADER_MAP).map((column) => [column, '']))],
|
||||
Object.keys(SHEET_HEADER_MAP).map((column) => ({ key: column, header: column })),
|
||||
'bulk-orders-template'
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
if (!tenantId) {
|
||||
OpenToast('Choose the client first', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!pickup) {
|
||||
OpenToast('Choose the branch orders are collected from', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
OpenToast('Upload a sheet of recipients first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
/* A row with no coordinates would post a booking at 0,0. Blocking here is
|
||||
honest; the solver cannot route a parcel to the Gulf of Guinea. */
|
||||
const incomplete = rows.find((row) => !row.address);
|
||||
if (incomplete) {
|
||||
OpenToast(`Add a delivery address for ${incomplete.firstname || 'one of the rows'} before submitting`, 'warning', 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
const bookings = rows.map((row) => ({
|
||||
tenantid: Number(tenantId),
|
||||
pickupaddress: pickup.address || '',
|
||||
pickuppincode: pickup.pincode || '',
|
||||
pickupcity: pickup.city || '',
|
||||
pickuplatitude: Number(pickup.latitude) || 0,
|
||||
pickuplongitude: Number(pickup.longitude) || 0,
|
||||
customer_phone: row.contactno?.toString() || '',
|
||||
customer_name: row.firstname || '',
|
||||
deliveryaddress: row.address || '',
|
||||
deliverypincode: row.postcode?.toString() || '',
|
||||
deliverycity: row.city || '',
|
||||
deliverylatitude: Number(row.latitude) || 0,
|
||||
deliverylongitude: Number(row.longitude) || 0,
|
||||
service_option: 'Normal',
|
||||
finalprice: Number(row.totalcharge) || 0,
|
||||
notes,
|
||||
parcels: Array.from({ length: Math.max(1, Number(row.quantity) || 1) }, () => ({
|
||||
itemcategory: 'General',
|
||||
itemdescription: row.description || 'Order',
|
||||
declaredvalue: Number(row.totalcharge) || 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (bookings.length > MAX_PER_CALL) {
|
||||
OpenToast(`This endpoint takes at most ${MAX_PER_CALL} bookings per call — split the sheet`, 'warning', 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
createBulk.mutate(bookings, {
|
||||
onSuccess: (res) => {
|
||||
if (res?.success === false) return;
|
||||
OpenToast(`${bookings.length} orders created`, 'success');
|
||||
navigate('/doormile/orders');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'firstname', header: 'Recipient', accessor: (row) => orDash(row.firstname) },
|
||||
{ key: 'contactno', header: 'Phone', accessor: (row) => orDash(row.contactno) },
|
||||
{
|
||||
key: 'address',
|
||||
header: 'Address',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
|
||||
{orDash(row.address)}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{ key: 'city', header: 'City', accessor: (row) => orDash(row.city), hideBelow: 'md' },
|
||||
{ key: 'postcode', header: 'Pincode', accessor: (row) => orDash(row.postcode), hideBelow: 'lg' },
|
||||
{ key: 'description', header: 'Item', accessor: (row) => orDash(row.description), hideBelow: 'lg' },
|
||||
{ key: 'quantity', header: 'Qty', align: 'right', accessor: (row) => row.quantity || 1 },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<IconButton
|
||||
label="Remove row"
|
||||
icon={Trash2}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRows((current) => current.filter((entry) => entry._rowkey !== row._rowkey))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Upload}
|
||||
title="Bulk upload"
|
||||
subtitle="One pickup branch, a sheet of recipients"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/orders">Orders</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Bulk upload</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader title="Pickup" subtitle="Every row in the sheet is collected from this branch." className="mb-4" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{isStaff && (
|
||||
<Field label="Client" required>
|
||||
<Select
|
||||
value={String(tenantId)}
|
||||
onValueChange={(value) => {
|
||||
setTenantId(value);
|
||||
setLocationId('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Branch" required hint="Where the parcels are collected from">
|
||||
<Select value={String(locationId)} onValueChange={setLocationId} disabled={!tenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={tenantId ? 'Choose a branch' : 'Choose a client first'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locations || []).map((entry) => {
|
||||
const id = entry.locationid ?? entry.tenantlocationid;
|
||||
return (
|
||||
<SelectItem key={id} value={String(id)}>
|
||||
{entry.locationname || `Branch #${id}`}
|
||||
{entry.isprimary ? ' · Primary' : ''}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Notes for every order" className="sm:col-span-2">
|
||||
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface radius="xl" padding="lg">
|
||||
<SectionHeader
|
||||
title="Recipients"
|
||||
subtitle={fileName ? `${fileName} · ${rows.length} rows` : 'Upload a CSV or Excel sheet'}
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={downloadTemplate}>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" /> Template
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<FileSpreadsheet className="mr-1.5 h-3.5 w-3.5" /> Choose file
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,.xls,.xlsx"
|
||||
onChange={onFile}
|
||||
className="sr-only"
|
||||
aria-label="Upload recipients sheet"
|
||||
/>
|
||||
|
||||
{missingColumns.length > 0 && (
|
||||
<Alert tone="warning" title="Some expected columns are missing" className="mb-4">
|
||||
{missingColumns.join(', ')} — rows will upload, but the fields those columns fill will be blank.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row._rowkey}
|
||||
pageSize={25}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Upload}
|
||||
title="No sheet uploaded"
|
||||
description="Download the template to see the column titles this reads, then upload your filled sheet."
|
||||
action={{ label: 'Choose file', icon: FileSpreadsheet, onClick: () => fileRef.current?.click() }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length > MAX_PER_CALL
|
||||
? `${rows.length} rows — this endpoint takes ${MAX_PER_CALL} at a time, so split the sheet.`
|
||||
: `${rows.length} order${rows.length === 1 ? '' : 's'} will be created.`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/orders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} loading={createBulk.isPending} disabled={!rows.length || !pickup}>
|
||||
Create {rows.length || ''} orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
382
src/pages/doormile/orders/Orders.jsx
Normal file
382
src/pages/doormile/orders/Orders.jsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, FileSpreadsheet, Package, Plus, Truck, Upload } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs } from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
|
||||
import { createAutomationDeliveries, getBookings } from '@/api/doormile';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { ORDER_STATUS_GROUPS, statusesInGroup } from '@/lib/orderStatusGroups';
|
||||
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Orders — every booking, grouped by where it sits in the operator's workflow.
|
||||
*
|
||||
* `GET /admin/bookings` documents no status, date or search parameters, so the
|
||||
* whole list is fetched once (up to the API's page cap) and filtered, counted
|
||||
* and searched entirely client-side. That is also why the tab badges can be
|
||||
* trusted: they are computed from the same rows the table renders, so a badge
|
||||
* can never disagree with what clicking it shows.
|
||||
*
|
||||
* **Assign orders** runs the external route optimiser, which sequences stops
|
||||
* and hands back a preview to reconcile before anything is committed.
|
||||
*/
|
||||
|
||||
const TABS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'assigned', label: 'Assigned' },
|
||||
{ value: 'active', label: 'Out for delivery' },
|
||||
{ value: 'delivered', label: 'Delivered' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Straight-line distance between pickup and drop.
|
||||
*
|
||||
* A booking carries coordinates but no road distance, so this is the honest
|
||||
* approximation rather than a number the API never gave us.
|
||||
*/
|
||||
const haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
if (![lat1, lon1, lat2, lon2].every((value) => Number.isFinite(Number(value)))) return undefined;
|
||||
const R = 6371;
|
||||
const toRad = (deg) => (Number(deg) * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const chargeOf = (booking) => booking?.serviceoptions?.[0]?.estimatedprice;
|
||||
|
||||
export default function Orders() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [tab, setTab] = useState('pending');
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
|
||||
const { data: customers = [] } = useCustomers();
|
||||
const bulkCancel = useBulkCancelBookings();
|
||||
|
||||
/* Polled: an operator watching the pending queue needs new bookings to
|
||||
appear without reaching for refresh. */
|
||||
const {
|
||||
data: bookings = [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['doormile', 'bookings', 'bulk'],
|
||||
queryFn: async () => (await getBookings(1, 1000)) || [],
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(customers || []).forEach((customer) => {
|
||||
const id = customer.appcustomerid ?? customer.customerid ?? customer.id;
|
||||
if (id != null) map.set(id, customer);
|
||||
});
|
||||
return map;
|
||||
}, [customers]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byStatus = {};
|
||||
(bookings || []).forEach((booking) => {
|
||||
const status = String(booking.status || '').toLowerCase();
|
||||
byStatus[status] = (byStatus[status] || 0) + 1;
|
||||
});
|
||||
return Object.fromEntries(
|
||||
Object.keys(ORDER_STATUS_GROUPS).map((group) => [
|
||||
group,
|
||||
statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0),
|
||||
])
|
||||
);
|
||||
}, [bookings]);
|
||||
|
||||
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(bookings || [])
|
||||
.filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase()))
|
||||
.filter((booking) =>
|
||||
matchesQuery(booking, ['bookingno', 'pickupaddress', 'deliveryaddress', 'notes'], debouncedSearch)
|
||||
),
|
||||
[bookings, tabStatuses, debouncedSearch]
|
||||
);
|
||||
|
||||
/* A row that scrolled out of the current tab must not stay silently selected
|
||||
— the assign buttons act on the selection, not on what is on screen. */
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [tab]);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
|
||||
[rows, selectedIds]
|
||||
);
|
||||
|
||||
const refetchOrders = () => queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
|
||||
/* The optimiser is a separate service with its own payload contract, so the
|
||||
booking fields are mapped onto the names it expects rather than passed
|
||||
through. Timestamps are formatted bare: Doormile's are naive IST, and
|
||||
treating them as UTC shifts the solver's deadline by five and a half
|
||||
hours. */
|
||||
const optimise = useMutation({
|
||||
mutationFn: createAutomationDeliveries,
|
||||
onSuccess: (data, variables) => {
|
||||
OpenToast('Orders optimised', 'success');
|
||||
refetchOrders();
|
||||
setSelectedIds([]);
|
||||
navigate('/doormile/dispatch/preview', {
|
||||
state: {
|
||||
dispatchPreviewData: data,
|
||||
aiMode: 1,
|
||||
selectedMode: { value: 1 },
|
||||
deliveryData: variables?.deliveries || [],
|
||||
startdate: dayjs().format('YYYY-MM-DD'),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => OpenToast(messageOf(err, 'Optimisation failed'), 'error', 4000),
|
||||
});
|
||||
|
||||
const assignOrders = () => {
|
||||
if (!selectedOrders.length) return;
|
||||
const deliveries = selectedOrders.map((booking) => {
|
||||
const charge = chargeOf(booking);
|
||||
return {
|
||||
...booking,
|
||||
deliveryid: 0,
|
||||
deliverydate: dayjs(booking.serviceoptions?.[0]?.estimateddeliveryat || booking.createdat).format(
|
||||
'YYYY-MM-DD HH:mm:ss'
|
||||
),
|
||||
assigntime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
||||
orderstatus: 'pending',
|
||||
orderamount: charge,
|
||||
droplat: booking.deliverylatitude,
|
||||
droplon: booking.deliverylongitude,
|
||||
pickuplat: booking.pickuplatitude,
|
||||
pickuplon: booking.pickuplongitude,
|
||||
ordernotes: booking.notes,
|
||||
deliverycharges: charge,
|
||||
pickuplocation: booking.pickupaddress,
|
||||
deliverylocation: booking.deliveryaddress,
|
||||
};
|
||||
});
|
||||
|
||||
optimise.mutate({
|
||||
deliveries,
|
||||
selectedMode: { value: 1 },
|
||||
hypertuning_params: 'balanced',
|
||||
absent_riders: [],
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'bookingno',
|
||||
header: 'Order',
|
||||
sortable: true,
|
||||
accessor: (row) => row.bookingno || `#${row.bookingid}`,
|
||||
cell: (row) => {
|
||||
const created = parseDoormileTimestamp(row.createdat);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">
|
||||
{row.bookingno || `#${row.bookingid}`}
|
||||
</p>
|
||||
<p className="text-caption tabular-nums text-ink-3">
|
||||
{created.isValid() ? created.format('hh:mm A · DD MMM YY') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'route',
|
||||
header: 'Route (pickup → drop)',
|
||||
sortValue: (row) => row.pickupaddress || '',
|
||||
cell: (row) => {
|
||||
const customer = customerMap.get(row.appcustomerid);
|
||||
const dropName =
|
||||
customer?.firstname ||
|
||||
customer?.name ||
|
||||
(row.appcustomerid ? `Customer #${row.appcustomerid}` : 'Drop');
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
<span className="font-medium text-ink-1">Pickup</span>
|
||||
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
|
||||
</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
<span className="font-medium text-ink-2">{dropName}</span>
|
||||
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
|
||||
{row.parcels?.length > 1 ? ` · ${row.parcels.length} parcels` : ''}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Km',
|
||||
align: 'center',
|
||||
sortValue: (row) =>
|
||||
haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude) ?? -1,
|
||||
accessor: (row) =>
|
||||
formatKm(haversineKm(row.pickuplatitude, row.pickuplongitude, row.deliverylatitude, row.deliverylongitude)),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'charges',
|
||||
header: 'Charges',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(chargeOf(row)) || 0,
|
||||
accessor: (row) => currency(chargeOf(row)),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
],
|
||||
[customerMap]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ key: 'bookingno', header: 'Order', value: (row) => row.bookingno || `#${row.bookingid}` },
|
||||
{ key: 'status', header: 'Status' },
|
||||
{ key: 'pickupaddress', header: 'Pickup' },
|
||||
{ key: 'deliveryaddress', header: 'Drop' },
|
||||
{ key: 'charges', header: 'Charges', value: (row) => chargeOf(row) },
|
||||
{ key: 'createdat', header: 'Created', value: (row) => orDash(row.createdat) },
|
||||
];
|
||||
|
||||
const canAssign = tab === 'pending' && selectedOrders.length > 0;
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
title="Orders"
|
||||
subtitle="Every booking, grouped by where it sits in the workflow"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/orders/createorders')}>
|
||||
<Upload className="mr-1.5 h-4 w-4" /> Bulk upload
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/doormile/orders/create')}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New order
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<Tabs
|
||||
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] ?? 0 }))}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by order number, address or note"
|
||||
className="w-72"
|
||||
/>
|
||||
{selectedIds.length > 0 && tab !== 'cancelled' && (
|
||||
<Button variant="outline" onClick={() => setCancelOpen(true)}>
|
||||
<Ban className="mr-1.5 h-4 w-4" /> Cancel {selectedIds.length}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, exportColumns, `orders-${tab}-${dayjs().format('YYYYMMDD')}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length === (counts[tab] ?? 0)
|
||||
? `${counts[tab] ?? 0} orders`
|
||||
: `${rows.length} of ${counts[tab] ?? 0} orders`}
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.bookingid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'bookingno', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
debouncedSearch ? undefined : (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={`Nothing ${TABS.find((entry) => entry.value === tab)?.label.toLowerCase()}`}
|
||||
description="Orders land here as they are created and move through the workflow."
|
||||
action={{ label: 'New order', icon: Plus, onClick: () => navigate('/doormile/orders/create') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{canAssign && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full border border-border bg-surface p-2 shadow-xl"
|
||||
>
|
||||
<Button onClick={assignOrders} loading={optimise.isPending}>
|
||||
<Truck className="mr-1.5 h-4 w-4" /> Assign orders ({selectedOrders.length})
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={cancelOpen}
|
||||
onOpenChange={setCancelOpen}
|
||||
title={`Cancel ${selectedIds.length} order${selectedIds.length === 1 ? '' : 's'}?`}
|
||||
description="Cancelled orders leave the dispatch queue. This cannot be undone from the console."
|
||||
confirmLabel="Cancel orders"
|
||||
busy={bulkCancel.isPending}
|
||||
onConfirm={() =>
|
||||
bulkCancel.mutate(selectedIds, {
|
||||
onSettled: () => {
|
||||
setCancelOpen(false);
|
||||
setSelectedIds([]);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
237
src/pages/doormile/orders/OrdersPreview.jsx
Normal file
237
src/pages/doormile/orders/OrdersPreview.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Bike, Package, Send } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Grid, KpiCard, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { finalCreatedeliveries, notifyRider } from '@/api/doormile';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
import { useRiderOptions } from '@/lib/doormileHooks';
|
||||
import { currency, km as formatKm, number, orDash } from '@/lib/doormileFormat';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* The optimiser's proposal, before anything is committed.
|
||||
*
|
||||
* Reached from Orders after the solver runs. Every row can still have its rider
|
||||
* changed here; committing calls `assign-miler` per order and then notifies
|
||||
* each distinct rider once.
|
||||
*
|
||||
* The delivery amount is recomputed from the rate card the solver echoed back
|
||||
* rather than trusted as returned: the solver is a separate service whose
|
||||
* pricing is not this backend's, and a figure shown here has to be the one that
|
||||
* will actually be charged.
|
||||
*/
|
||||
|
||||
export default function OrdersPreview() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deliveryList = location.state?.deliverylist;
|
||||
const aiMode = location.state?.aiMode;
|
||||
|
||||
const [rows, setRows] = useState([]);
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
useEffect(() => {
|
||||
if (!deliveryList?.length) {
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setRows(
|
||||
deliveryList.map((row) => {
|
||||
const cumulativeKms = Number(row.cumulativekms || 0);
|
||||
const minKm = Number(row.minkm || 0);
|
||||
const basePrice = Number(row.baseprice || 0);
|
||||
const pricePerKm = Number(row.priceperkm || 0);
|
||||
return {
|
||||
...row,
|
||||
deliveryamt: cumulativeKms <= minKm ? basePrice : (cumulativeKms - minKm) * pricePerKm + basePrice,
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [deliveryList]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const riderCount = new Set(rows.map((row) => row.userid).filter(Boolean)).size;
|
||||
return {
|
||||
orders: rows.length,
|
||||
riders: riderCount,
|
||||
kms: rows.reduce((sum, row) => sum + (Number(row.cumulativekms) || 0), 0),
|
||||
amount: rows.reduce((sum, row) => sum + (Number(row.deliveryamt) || 0), 0),
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const changeRider = (rowKey, userId) => {
|
||||
const rider = (riders || []).find((entry) => String(entry.userid) === String(userId));
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
String(row.orderid ?? row.bookingid) === String(rowKey)
|
||||
? { ...row, userid: rider?.userid, username: rider?.displayname || rider?.authname, rider_name: rider?.displayname }
|
||||
: row
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Commits the proposal.
|
||||
*
|
||||
* `finalCreatedeliveries` validates each solver row's id against the tenant's
|
||||
* real booking list before assigning, because the solver does not reliably
|
||||
* preserve any one id field. It answers with the resolved miler profile ids,
|
||||
* which is what notify needs — the solver's own rider id is neither a userid
|
||||
* nor a profile id and notifying with it fails silently.
|
||||
*/
|
||||
const commit = useMutation({
|
||||
mutationFn: finalCreatedeliveries,
|
||||
onSuccess: (data) => {
|
||||
(data?.resolvedMilerProfileIds || []).forEach((profileId) => {
|
||||
notifyRider(profileId).catch((err) => logger.error('OrdersPreview: notifyRider failed', err.message));
|
||||
});
|
||||
OpenToast(`${data?.assigned ?? rows.length} orders assigned`, 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['doormile', 'bookings'] });
|
||||
navigate('/doormile/orders');
|
||||
},
|
||||
onError: (err) => OpenToast(messageOf(err, 'Could not assign these orders'), 'error', 4000),
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const base = [];
|
||||
|
||||
/* Zone only exists on the solver's own output, so the column appears only
|
||||
when this preview came from the optimiser. */
|
||||
if (aiMode == 1) {
|
||||
base.push({
|
||||
key: 'zone_name',
|
||||
header: 'Zone',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status="pending" label={orDash(row.zone_name)} tone="info" size="sm" />,
|
||||
});
|
||||
}
|
||||
|
||||
base.push(
|
||||
{
|
||||
key: 'tenantname',
|
||||
header: 'Client',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.tenantname)}</p>
|
||||
<p className="truncate text-caption text-ink-3">{orDash(row.locationname)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'pickupcustomer',
|
||||
header: 'Pickup',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickuplocation || ''}>
|
||||
{orDash(row.pickupcustomer || row.pickuplocation)}
|
||||
</p>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliverycustomer',
|
||||
header: 'Delivery',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-1">{orDash(row.deliverycustomer)}</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliverylocation || ''}>
|
||||
{orDash(row.deliverylocation)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cumulativekms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.cumulativekms) || 0,
|
||||
accessor: (row) => formatKm(row.cumulativekms),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'deliveryamt',
|
||||
header: 'Charge',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliveryamt) || 0,
|
||||
accessor: (row) => currency(row.deliveryamt),
|
||||
},
|
||||
{
|
||||
key: 'rider',
|
||||
header: 'Rider',
|
||||
cell: (row) => (
|
||||
<Select
|
||||
value={row.userid ? String(row.userid) : ''}
|
||||
onValueChange={(value) => changeRider(row.orderid ?? row.bookingid, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44">
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(riders || []).map((rider) => (
|
||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||
{rider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
return base;
|
||||
|
||||
}, [aiMode, riders]);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Package}
|
||||
title="Assignment preview"
|
||||
subtitle="Nothing is committed until you assign — riders can still be changed here"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/orders')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to orders
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => commit.mutate({ deliveries: rows })}
|
||||
loading={commit.isPending}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<Send className="mr-1.5 h-4 w-4" /> Assign {rows.length || ''} orders
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders" tone="brand" index={0} />
|
||||
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Riders" tone="navy" index={1} />
|
||||
<KpiCard icon={Package} value={formatKm(stats.kms)} label="Total distance" tone="accent" index={2} />
|
||||
<KpiCard icon={Package} value={currency(stats.amount, '₹0')} label="Total charge" tone="success" index={3} />
|
||||
</Grid>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row, index) => row.orderid ?? row.bookingid ?? index}
|
||||
pageSize={50}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Nothing to preview"
|
||||
description="Select orders on the Orders page and run Assign orders to get here."
|
||||
action={{ label: 'Go to Orders', onClick: () => navigate('/doormile/orders') }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
583
src/pages/doormile/pricing/Pricing.jsx
Normal file
583
src/pages/doormile/pricing/Pricing.jsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Building2, Calculator, IndianRupee, MapPin, Pencil, Plus, Tag, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button, ConfirmModal, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard,
|
||||
Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Stack, StatusBadge, Surface,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useAppLocations, useCreatePricing, useDeletePricing, usePricing, useSimulatePricing,
|
||||
useTenants, useUpdatePricing,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Client pricing — the per-tenant rate cards quotes are calculated from.
|
||||
*
|
||||
* This is `/admin/pricing`, the tenant-scoped rules. Doormile's own internal
|
||||
* banding lives on a separate resource with no tenant concept at all and is not
|
||||
* edited here.
|
||||
*/
|
||||
|
||||
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
||||
|
||||
/** Confirmed live: every real pricing row uses this exact casing. */
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'Active', label: 'Active' },
|
||||
{ value: 'Inactive', label: 'Inactive' },
|
||||
];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
pricingid: null,
|
||||
tenantid: '',
|
||||
applocationid: '',
|
||||
vehicletype: 'Bike',
|
||||
baseprice: '',
|
||||
baseweight: '',
|
||||
priceperkg: '',
|
||||
basedistance: '',
|
||||
priceperkm: '',
|
||||
handlingcharges: '',
|
||||
priority: 1,
|
||||
status: 'Active',
|
||||
};
|
||||
|
||||
const EMPTY_QUOTE = { tenantid: '', applocationid: '', vehicletype: 'Bike', weight: '', distance: '' };
|
||||
|
||||
const ALL_ZONES = 'all';
|
||||
|
||||
export default function Pricing() {
|
||||
const { data: pricing = [], isLoading, isFetching } = usePricing();
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [zone, setZone] = useState(ALL_ZONES);
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const [quoteForm, setQuoteForm] = useState(EMPTY_QUOTE);
|
||||
|
||||
const createPricing = useCreatePricing();
|
||||
const updatePricing = useUpdatePricing();
|
||||
const deletePricing = useDeletePricing();
|
||||
const simulate = useSimulatePricing();
|
||||
|
||||
const tenantMap = useMemo(
|
||||
() => new Map((tenants || []).map((tenant) => [tenant.tenantid, tenant])),
|
||||
[tenants]
|
||||
);
|
||||
const zoneMap = useMemo(
|
||||
() => new Map((locations || []).map((location) => [location.applocationid, location.locationname])),
|
||||
[locations]
|
||||
);
|
||||
|
||||
const tenantName = (id) => tenantMap.get(id)?.tenantname || (id ? `Tenant #${id}` : '—');
|
||||
const zoneName = (id) => zoneMap.get(id) || (id ? `Zone #${id}` : 'All zones');
|
||||
|
||||
/* Zone is applied before the stats are taken, so the KPI row describes what
|
||||
the table is actually showing rather than the whole account. */
|
||||
const zonePricing = useMemo(() => {
|
||||
if (zone === ALL_ZONES) return pricing || [];
|
||||
return (pricing || []).filter((row) => String(row.applocationid) === String(zone));
|
||||
}, [pricing, zone]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = zonePricing.length;
|
||||
const tenantsPriced = new Set(zonePricing.map((row) => row.tenantid).filter(Boolean)).size;
|
||||
const avgBase = total
|
||||
? zonePricing.reduce((sum, row) => sum + (Number(row.baseprice) || 0), 0) / total
|
||||
: 0;
|
||||
const active = zonePricing.filter((row) => String(row.status || '').toLowerCase() === 'active').length;
|
||||
return { total, tenantsPriced, avgBase, active };
|
||||
}, [zonePricing]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
zonePricing.filter((row) =>
|
||||
matchesQuery(row, ['vehicletype', 'status', (r) => tenantName(r.tenantid), (r) => zoneName(r.applocationid)], debouncedSearch)
|
||||
),
|
||||
|
||||
[zonePricing, debouncedSearch, tenantMap, zoneMap]
|
||||
);
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
const setQuote = (key) => (value) => setQuoteForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
pricingid: row.pricingid,
|
||||
tenantid: row.tenantid ?? '',
|
||||
applocationid: row.applocationid ?? '',
|
||||
vehicletype: row.vehicletype || 'Bike',
|
||||
baseprice: row.baseprice ?? '',
|
||||
baseweight: row.baseweight ?? '',
|
||||
priceperkg: row.priceperkg ?? '',
|
||||
basedistance: row.basedistance ?? '',
|
||||
priceperkm: row.priceperkm ?? '',
|
||||
handlingcharges: row.handlingcharges ?? '',
|
||||
priority: row.priority ?? 1,
|
||||
status: row.status || 'Active',
|
||||
});
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.tenantid) nextErrors.tenantid = 'Choose the client this rate applies to';
|
||||
if (form.baseprice === '') nextErrors.baseprice = 'Enter a base price';
|
||||
if (form.priceperkm === '') nextErrors.priceperkm = 'Enter a per-kilometre rate';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
const data = {
|
||||
tenantid: Number(form.tenantid),
|
||||
applocationid: form.applocationid ? Number(form.applocationid) : undefined,
|
||||
vehicletype: form.vehicletype,
|
||||
baseprice: Number(form.baseprice),
|
||||
baseweight: Number(form.baseweight) || 0,
|
||||
priceperkg: Number(form.priceperkg) || 0,
|
||||
basedistance: Number(form.basedistance) || 0,
|
||||
priceperkm: Number(form.priceperkm),
|
||||
handlingcharges: Number(form.handlingcharges) || 0,
|
||||
/* `effectivefrom` is required and confirmed live to reject a bare
|
||||
YYYY-MM-DD — it needs a full ISO datetime. */
|
||||
effectivefrom: new Date().toISOString(),
|
||||
currency: 'INR',
|
||||
priority: Number(form.priority) || 1,
|
||||
status: form.status,
|
||||
};
|
||||
|
||||
const mutation = form.pricingid ? updatePricing : createPricing;
|
||||
const variables = form.pricingid ? { id: form.pricingid, data } : data;
|
||||
mutation.mutate(variables, { onSuccess: (res) => res?.success !== false && setDialogOpen(false) });
|
||||
};
|
||||
|
||||
const runQuote = () => {
|
||||
if (!quoteForm.tenantid || quoteForm.distance === '') return;
|
||||
simulate.mutate({
|
||||
tenantid: Number(quoteForm.tenantid),
|
||||
applocationid: quoteForm.applocationid ? Number(quoteForm.applocationid) : undefined,
|
||||
vehicletype: quoteForm.vehicletype,
|
||||
weight: Number(quoteForm.weight) || 0,
|
||||
distance: Number(quoteForm.distance) || 0,
|
||||
});
|
||||
};
|
||||
|
||||
const closeQuote = () => {
|
||||
setQuoteOpen(false);
|
||||
simulate.reset();
|
||||
setQuoteForm(EMPTY_QUOTE);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'tenant',
|
||||
header: 'Client',
|
||||
sortable: true,
|
||||
accessor: (row) => tenantName(row.tenantid),
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{tenantName(row.tenantid)}</p>
|
||||
<p className="text-caption text-ink-3">{zoneName(row.applocationid)}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'vehicletype', header: 'Vehicle', sortable: true, accessor: (row) => orDash(row.vehicletype) },
|
||||
{
|
||||
key: 'baseprice',
|
||||
header: 'Base',
|
||||
sortValue: (row) => Number(row.baseprice) || 0,
|
||||
accessor: (row) => currency(row.baseprice),
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'priceperkm',
|
||||
header: 'Per km',
|
||||
sortValue: (row) => Number(row.priceperkm) || 0,
|
||||
accessor: (row) => currency(row.priceperkm),
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'priceperkg',
|
||||
header: 'Per kg',
|
||||
sortValue: (row) => Number(row.priceperkg) || 0,
|
||||
accessor: (row) => currency(row.priceperkg),
|
||||
align: 'right',
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'handlingcharges',
|
||||
header: 'Handling',
|
||||
sortValue: (row) => Number(row.handlingcharges) || 0,
|
||||
accessor: (row) => currency(row.handlingcharges),
|
||||
align: 'right',
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit rule" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton label="Delete rule" icon={Trash2} variant="ghost" size="sm" onClick={() => setPendingDelete(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
[tenantMap, zoneMap]
|
||||
);
|
||||
|
||||
const quoteResult = simulate.data?.data || simulate.data;
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Tag}
|
||||
title="Pricing"
|
||||
subtitle="Per-client rate cards — what a delivery is quoted at"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setQuoteOpen(true)}>
|
||||
<Calculator className="mr-1.5 h-4 w-4" /> Get a quote
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New rule
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Tag} value={stats.total} label="Pricing rules" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={Building2}
|
||||
value={stats.tenantsPriced}
|
||||
label="Clients priced"
|
||||
tone="navy"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={IndianRupee}
|
||||
value={currency(stats.avgBase, '₹0')}
|
||||
label="Average base price"
|
||||
tone="accent"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard icon={MapPin} value={stats.active} label="Active rules" tone="success" index={3} loading={isLoading} />
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by client, zone or vehicle"
|
||||
count={{ shown: rows.length, total: zonePricing.length, noun: 'rules' }}
|
||||
>
|
||||
<Select value={String(zone)} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ZONES}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((location) => location.applocationid)
|
||||
.map((location) => (
|
||||
<SelectItem key={location.applocationid} value={String(location.applocationid)}>
|
||||
{location.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row, index) => row.pricingid ?? index}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'tenant', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || zone !== ALL_ZONES}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setZone(ALL_ZONES);
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || zone !== ALL_ZONES ? undefined : (
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
title="No pricing rules yet"
|
||||
description="Add a rate card so this client's deliveries can be quoted."
|
||||
action={{ label: 'New rule', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={form.pricingid ? 'Edit pricing rule' : 'New pricing rule'}
|
||||
description="Leave the zone empty to price a client the same way everywhere."
|
||||
icon={Tag}
|
||||
size="lg"
|
||||
busy={createPricing.isPending || updatePricing.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createPricing.isPending || updatePricing.isPending}>
|
||||
{form.pricingid ? 'Save changes' : 'Create rule'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Client" required error={errors.tenantid}>
|
||||
<Select value={String(form.tenantid)} onValueChange={set('tenantid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Zone" hint="Optional — applies everywhere when left empty">
|
||||
<Select
|
||||
value={form.applocationid === '' ? ALL_ZONES : String(form.applocationid)}
|
||||
onValueChange={(value) => set('applocationid')(value === ALL_ZONES ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ZONES}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((location) => location.applocationid)
|
||||
.map((location) => (
|
||||
<SelectItem key={location.applocationid} value={String(location.applocationid)}>
|
||||
{location.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Vehicle type">
|
||||
<Select value={form.vehicletype} onValueChange={set('vehicletype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Base price" required error={errors.baseprice}>
|
||||
<Input type="number" value={form.baseprice} onChange={(e) => set('baseprice')(e.target.value)} placeholder="40" />
|
||||
</Field>
|
||||
|
||||
<Field label="Price per km" required error={errors.priceperkm}>
|
||||
<Input type="number" value={form.priceperkm} onChange={(e) => set('priceperkm')(e.target.value)} placeholder="8" />
|
||||
</Field>
|
||||
|
||||
<Field label="Base distance (km)" hint="Distance included in the base price">
|
||||
<Input type="number" value={form.basedistance} onChange={(e) => set('basedistance')(e.target.value)} placeholder="3" />
|
||||
</Field>
|
||||
|
||||
<Field label="Base weight (kg)" hint="Weight included in the base price">
|
||||
<Input type="number" value={form.baseweight} onChange={(e) => set('baseweight')(e.target.value)} placeholder="5" />
|
||||
</Field>
|
||||
|
||||
<Field label="Price per kg">
|
||||
<Input type="number" value={form.priceperkg} onChange={(e) => set('priceperkg')(e.target.value)} placeholder="4" />
|
||||
</Field>
|
||||
|
||||
<Field label="Handling charges">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.handlingcharges}
|
||||
onChange={(e) => set('handlingcharges')(e.target.value)}
|
||||
placeholder="10"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Priority" hint="Lower wins when two rules could both apply">
|
||||
<Input type="number" value={form.priority} onChange={(e) => set('priority')(e.target.value)} placeholder="1" />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={quoteOpen}
|
||||
onOpenChange={(open) => !open && closeQuote()}
|
||||
title="Get a quote"
|
||||
description="Runs the same calculation the booking flow uses, without creating anything."
|
||||
icon={Calculator}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={closeQuote}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={runQuote} disabled={simulate.isPending || !quoteForm.tenantid || quoteForm.distance === ''}>
|
||||
Get quote
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
<Field label="Client" required>
|
||||
<Select value={String(quoteForm.tenantid)} onValueChange={setQuote('tenantid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Zone">
|
||||
<Select
|
||||
value={quoteForm.applocationid === '' ? ALL_ZONES : String(quoteForm.applocationid)}
|
||||
onValueChange={(value) => setQuote('applocationid')(value === ALL_ZONES ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_ZONES}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((location) => location.applocationid)
|
||||
.map((location) => (
|
||||
<SelectItem key={location.applocationid} value={String(location.applocationid)}>
|
||||
{location.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field label="Vehicle">
|
||||
<Select value={quoteForm.vehicletype} onValueChange={setQuote('vehicletype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Weight (kg)">
|
||||
<Input type="number" value={quoteForm.weight} onChange={(e) => setQuote('weight')(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Distance (km)" required>
|
||||
<Input type="number" value={quoteForm.distance} onChange={(e) => setQuote('distance')(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{simulate.isPending && <p className="text-body-sm text-ink-3">Calculating…</p>}
|
||||
|
||||
{quoteResult ? (
|
||||
<Surface variant="sunken" padding="sm" radius="lg">
|
||||
{simulate.data?.success === false ? (
|
||||
<p className="text-body-sm text-destructive">{simulate.data.message || 'No quote available'}</p>
|
||||
) : (
|
||||
/* The quote response has no documented schema, so every field
|
||||
it returns is shown rather than a hand-picked subset that
|
||||
might omit the one that matters. */
|
||||
<dl className="space-y-1.5">
|
||||
{Object.entries(quoteResult).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<dt className="text-caption uppercase tracking-wide text-ink-3">{key}</dt>
|
||||
<dd className="text-body-sm font-semibold text-ink-1">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</Surface>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete this pricing rule?"
|
||||
description={`${tenantName(pendingDelete?.tenantid)} · ${pendingDelete?.vehicletype || ''} will no longer be priced by this rule.`}
|
||||
confirmLabel="Delete rule"
|
||||
busy={deletePricing.isPending}
|
||||
onConfirm={() => {
|
||||
deletePricing.mutate(pendingDelete.pricingid, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
258
src/pages/doormile/reports/OrdersDetails.jsx
Normal file
258
src/pages/doormile/reports/OrdersDetails.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileSpreadsheet, ListOrdered } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Input, PageHeader, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useDeliveries, useRiderOptions, useTenants } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Orders details — the row-level export behind the summary.
|
||||
*
|
||||
* `GET /admin/bookings` accepts no date, status or search parameter, so the
|
||||
* range is applied when the rows are built and everything else is filtered
|
||||
* here. Pages are drained as they arrive rather than paged through by hand: a
|
||||
* report that silently stopped at the first hundred rows would be wrong in a
|
||||
* way nobody could see.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function OrdersDetails() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [tenantId, setTenantId] = useState(ALL);
|
||||
const [riderId, setRiderId] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [collected, setCollected] = useState([]);
|
||||
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({
|
||||
page,
|
||||
pageSize: 200,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
/* A filter change invalidates everything already drained. */
|
||||
useEffect(() => {
|
||||
setCollected([]);
|
||||
setPage(1);
|
||||
}, [range.from, range.to]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.rows) return;
|
||||
setCollected((current) => {
|
||||
const seen = new Set(current.map((row) => row.orderheaderid));
|
||||
const merged = [...current];
|
||||
data.rows.forEach((row) => {
|
||||
if (!seen.has(row.orderheaderid)) merged.push(row);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
if (data.nextPage) setPage(data.nextPage);
|
||||
}, [data]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
collected
|
||||
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
||||
.filter((row) => riderId === ALL || String(row.userid) === riderId)
|
||||
.filter((row) =>
|
||||
matchesQuery(
|
||||
row,
|
||||
['orderid', 'tenantname', 'deliverycustomer', 'ridername', 'pickupaddress', 'deliveryaddress'],
|
||||
debouncedSearch
|
||||
)
|
||||
),
|
||||
[collected, tenantId, riderId, debouncedSearch]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'orderid',
|
||||
header: 'Order',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
|
||||
<p className="text-caption text-ink-3">{formatDoormileTimestamp(row.orderdate, 'DD MMM, hh:mm A')}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'tenantname', header: 'Client', sortable: true, accessor: (row) => orDash(row.tenantname), hideBelow: 'md' },
|
||||
{
|
||||
key: 'pickupaddress',
|
||||
header: 'Pickup',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
{orDash(row.pickupaddress)}
|
||||
</p>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliveryaddress',
|
||||
header: 'Drop',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-1">{orDash(row.deliverycustomer)}</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
{orDash(row.deliveryaddress)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ridername',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
accessor: (row) => orDash(row.ridername),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.kms) || 0,
|
||||
accessor: (row) => formatKm(row.kms),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliverycharges',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliverycharges) || 0,
|
||||
accessor: (row) => currency(row.deliverycharges),
|
||||
},
|
||||
{
|
||||
key: 'orderstatus',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.orderstatus} dot size="sm" />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ key: 'orderid', header: 'Order' },
|
||||
{ key: 'orderdate', header: 'Created' },
|
||||
{ key: 'tenantname', header: 'Client' },
|
||||
{ key: 'deliverycustomer', header: 'Customer' },
|
||||
{ key: 'pickupaddress', header: 'Pickup' },
|
||||
{ key: 'deliveryaddress', header: 'Drop' },
|
||||
{ key: 'ridername', header: 'Rider' },
|
||||
{ key: 'kms', header: 'Km' },
|
||||
{ key: 'deliverycharges', header: 'Amount' },
|
||||
{ key: 'orderstatus', header: 'Status' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={ListOrdered}
|
||||
title="Orders details"
|
||||
subtitle={`${collected.length} rows loaded · ${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
|
||||
actions={
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.from}
|
||||
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
|
||||
aria-label="From date"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.to}
|
||||
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
|
||||
aria-label="To date"
|
||||
className="w-40"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by order, client, customer, rider or address"
|
||||
count={{ shown: rows.length, total: collected.length, noun: 'orders' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, exportColumns, `orders-details-${range.from}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={tenantId} onValueChange={setTenantId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All clients</SelectItem>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={riderId} onValueChange={setRiderId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All riders" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All riders</SelectItem>
|
||||
{(riders || []).map((rider) => (
|
||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||
{rider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.orderheaderid}
|
||||
loading={isLoading && collected.length === 0}
|
||||
refreshing={isFetching && collected.length > 0}
|
||||
pageSize={50}
|
||||
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || tenantId !== ALL || riderId !== ALL}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setTenantId(ALL);
|
||||
setRiderId(ALL);
|
||||
}}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={ListOrdered}
|
||||
title="Nothing in this range"
|
||||
description="No dispatched orders were recorded for the dates selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
313
src/pages/doormile/reports/OrdersSummary.jsx
Normal file
313
src/pages/doormile/reports/OrdersSummary.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { BarChart3, CheckCircle2, FileSpreadsheet, IndianRupee, Package, Timer } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useAppLocations, useReportLocationSummary, useReportSummary, useTenantLocations, useTenants,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Orders summary — the same counts the Orders page shows, grouped.
|
||||
*
|
||||
* Grouping follows the filter: with no client chosen the report comes back per
|
||||
* client, and choosing one switches the source to the per-site breakdown. Those
|
||||
* are two different endpoints returning two differently-shaped rows, which is
|
||||
* why the first column changes its name with the grouping.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function OrdersSummary() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [zone, setZone] = useState(ALL);
|
||||
const [tenantId, setTenantId] = useState(ALL);
|
||||
const [locationId, setLocationId] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: tenantLocations = [] } = useTenantLocations(tenantId === ALL ? null : tenantId);
|
||||
|
||||
const groupedByLocation = tenantId !== ALL;
|
||||
|
||||
const appId = zone === ALL ? '' : zone;
|
||||
const tenantParam = tenantId === ALL ? '' : tenantId;
|
||||
const locationParam = locationId === ALL ? '' : locationId;
|
||||
|
||||
const summary = useReportSummary(appId, tenantParam, locationParam, range.from, range.to, {
|
||||
enabled: !groupedByLocation,
|
||||
});
|
||||
const locationSummary = useReportLocationSummary(appId, tenantParam, locationParam, range.from, range.to, {
|
||||
enabled: groupedByLocation,
|
||||
});
|
||||
|
||||
const query = groupedByLocation ? locationSummary : summary;
|
||||
|
||||
/* The per-client endpoint answers with a single object on some accounts and
|
||||
an array on others; both are normalised to rows here rather than at four
|
||||
separate call sites downstream. */
|
||||
const rows = useMemo(() => {
|
||||
const data = query.data;
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.rows)) return data.rows;
|
||||
if (Array.isArray(data.by_tenant)) return data.by_tenant;
|
||||
if (Array.isArray(data.by_location)) return data.by_location;
|
||||
return [data];
|
||||
}, [query.data]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => rows.filter((row) => matchesQuery(row, ['tenantname', 'locationname', 'name'], debouncedSearch)),
|
||||
[rows, debouncedSearch]
|
||||
);
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
filteredRows.reduce(
|
||||
(acc, row) => ({
|
||||
totalOrders: acc.totalOrders + (Number(row.totalorders) || 0),
|
||||
pending: acc.pending + (Number(row.Orderspending ?? row.orderspending) || 0),
|
||||
completed: acc.completed + (Number(row.orderscompleted) || 0),
|
||||
cancelled: acc.cancelled + (Number(row.orderscancelled) || 0),
|
||||
collection: acc.collection + (Number(row.collectionamt) || 0),
|
||||
kms: acc.kms + (Number(row.kms) || 0),
|
||||
/* Charges and delivery amount are the same money reported under two
|
||||
names depending on the account; taking the larger avoids counting
|
||||
a row as zero when only one of them is populated. */
|
||||
amount: acc.amount + Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
|
||||
}),
|
||||
{ totalOrders: 0, pending: 0, completed: 0, cancelled: 0, collection: 0, kms: 0, amount: 0 }
|
||||
),
|
||||
[filteredRows]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: groupedByLocation ? 'Site' : 'Client',
|
||||
sortable: true,
|
||||
accessor: (row) => row.locationname || row.tenantname || row.name || '—',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">
|
||||
{row.locationname || row.tenantname || row.name || '—'}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalorders',
|
||||
header: 'Orders',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.totalorders) || 0,
|
||||
accessor: (row) => number(row.totalorders, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderspending',
|
||||
header: 'Pending',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.Orderspending ?? row.orderspending) || 0,
|
||||
accessor: (row) => number(row.Orderspending ?? row.orderspending, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderscompleted',
|
||||
header: 'Completed',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.orderscompleted) || 0,
|
||||
accessor: (row) => number(row.orderscompleted, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderscancelled',
|
||||
header: 'Cancelled',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.orderscancelled) || 0,
|
||||
accessor: (row) => number(row.orderscancelled, '0'),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'deliveriescompleted',
|
||||
header: 'Deliveries done',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliveriescompleted) || 0,
|
||||
accessor: (row) => number(row.deliveriescompleted, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.kms) || 0,
|
||||
accessor: (row) => number(row.kms, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'collectionamt',
|
||||
header: 'Collection',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.collectionamt) || 0,
|
||||
accessor: (row) => currency(row.collectionamt),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
|
||||
accessor: (row) => currency(Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0)),
|
||||
},
|
||||
],
|
||||
[groupedByLocation]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
title="Orders summary"
|
||||
subtitle={`${groupedByLocation ? 'By site' : 'By client'} · ${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
|
||||
actions={
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.from}
|
||||
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
|
||||
aria-label="From date"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.to}
|
||||
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
|
||||
aria-label="To date"
|
||||
className="w-40"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Package} value={number(stats.totalOrders, '0')} label="Total orders" tone="brand" index={0} loading={query.isLoading} />
|
||||
<KpiCard
|
||||
icon={Timer}
|
||||
value={number(stats.pending, '0')}
|
||||
label={`Pending · ${percentOf(stats.pending, stats.totalOrders)}%`}
|
||||
tone="warning"
|
||||
index={1}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={number(stats.completed, '0')}
|
||||
label={`Completed · ${percentOf(stats.completed, stats.totalOrders)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={IndianRupee}
|
||||
value={currency(stats.amount, '₹0')}
|
||||
label="Total amount"
|
||||
tone="navy"
|
||||
index={3}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by client or site"
|
||||
count={{ shown: filteredRows.length, total: rows.length, noun: 'rows' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(filteredRows, columns, `orders-summary-${range.from}`)}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={zone} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={tenantId}
|
||||
onValueChange={(value) => {
|
||||
setTenantId(value);
|
||||
setLocationId(ALL);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All clients</SelectItem>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{groupedByLocation && (
|
||||
<Select value={locationId} onValueChange={setLocationId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All sites" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All sites</SelectItem>
|
||||
{(tenantLocations || []).map((entry) => (
|
||||
<SelectItem key={entry.locationid} value={String(entry.locationid)}>
|
||||
{entry.locationname || `Site #${entry.locationid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={filteredRows}
|
||||
getRowId={(row, index) => row.tenantid ?? row.locationid ?? index}
|
||||
loading={query.isLoading}
|
||||
refreshing={query.isFetching && !query.isLoading}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'totalorders', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Nothing in this range"
|
||||
description="No orders were recorded for the dates and filters selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
217
src/pages/doormile/reports/Profitability.jsx
Normal file
217
src/pages/doormile/reports/Profitability.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Fuel, IndianRupee, Percent, TrendingDown, TrendingUp, Wallet } from 'lucide-react';
|
||||
import {
|
||||
DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Stack, StatusBadge, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useDeliveries } from '@/lib/doormileHooks';
|
||||
import {
|
||||
BASE_KM_LIMIT, BASE_REVENUE, EXTRA_RATE_KM, FIXED_COST_PER_SLOT, VARIABLE_RATE_KM,
|
||||
calcRiderMetrics, groupRowsByRider,
|
||||
} from '@/lib/profitability';
|
||||
import { currency, km as formatKm, number } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Profitability — what a day of deliveries actually earned, per rider.
|
||||
*
|
||||
* Revenue is ₹30 for the first 8 km and ₹6/km beyond it. Cost is ₹2.50/km of
|
||||
* fuel and wear, plus a slice of fixed salary for every slot the rider worked,
|
||||
* capped at three slots a day. Cancelled and skipped stops earn nothing and are
|
||||
* charged nothing — they still appear in the order count, because an operator
|
||||
* reading a rider's day needs to see them.
|
||||
*
|
||||
* Distance is the straight line between pickup and drop: a booking carries
|
||||
* coordinates but no road distance, so the real figure is somewhat higher than
|
||||
* what is shown here, which makes these margins the conservative read.
|
||||
*/
|
||||
|
||||
export default function Profitability() {
|
||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
const [page, setPage] = useState(1);
|
||||
const [collected, setCollected] = useState([]);
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({ page, pageSize: 200, from: date, to: date });
|
||||
|
||||
useEffect(() => {
|
||||
setCollected([]);
|
||||
setPage(1);
|
||||
}, [date]);
|
||||
|
||||
/* Drained rather than paged: a profit figure computed from the first page
|
||||
only would be confidently wrong, and nothing on screen would say so. */
|
||||
useEffect(() => {
|
||||
if (!data?.rows) return;
|
||||
setCollected((current) => {
|
||||
const seen = new Set(current.map((row) => row.orderheaderid));
|
||||
const merged = [...current];
|
||||
data.rows.forEach((row) => {
|
||||
if (!seen.has(row.orderheaderid)) merged.push(row);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
if (data.nextPage) setPage(data.nextPage);
|
||||
}, [data]);
|
||||
|
||||
const riders = useMemo(() => groupRowsByRider(collected), [collected]);
|
||||
|
||||
const enriched = useMemo(
|
||||
() =>
|
||||
riders
|
||||
.map((rider) => ({ ...rider, metrics: calcRiderMetrics(rider, date) }))
|
||||
/* A rider with no orders on this day is not a zero-margin rider — they
|
||||
simply did not work it, and averaging them in drags every figure. */
|
||||
.filter((rider) => rider.metrics.orders.length > 0),
|
||||
[riders, date]
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
enriched.reduce(
|
||||
(acc, rider) => ({
|
||||
revenue: acc.revenue + rider.metrics.revenue,
|
||||
cost: acc.cost + rider.metrics.totalCost,
|
||||
net: acc.net + rider.metrics.net,
|
||||
kms: acc.kms + rider.metrics.kms,
|
||||
orders: acc.orders + rider.metrics.orders.length,
|
||||
}),
|
||||
{ revenue: 0, cost: 0, net: 0, kms: 0, orders: 0 }
|
||||
),
|
||||
[enriched]
|
||||
);
|
||||
|
||||
const margin = totals.revenue > 0 ? (totals.net / totals.revenue) * 100 : 0;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'riderName',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{row.riderName}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{row.metrics.orders.length} orders · {row.metrics.slotCount} slot
|
||||
{row.metrics.slotCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Distance',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.kms,
|
||||
accessor: (row) => formatKm(row.metrics.kms),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
header: 'Revenue',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.revenue,
|
||||
accessor: (row) => currency(row.metrics.revenue),
|
||||
},
|
||||
{
|
||||
key: 'varCost',
|
||||
header: 'Fuel & wear',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.varCost,
|
||||
accessor: (row) => currency(row.metrics.varCost),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'fixedCost',
|
||||
header: 'Fixed',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.fixedCost,
|
||||
accessor: (row) => currency(row.metrics.fixedCost),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'Net',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.net,
|
||||
cell: (row) => (
|
||||
<span className={row.metrics.net >= 0 ? 'font-semibold text-success' : 'font-semibold text-destructive'}>
|
||||
{currency(row.metrics.net)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
header: 'Margin',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.margin,
|
||||
cell: (row) => (
|
||||
<StatusBadge
|
||||
status={row.metrics.margin >= 0 ? 'delivered' : 'cancelled'}
|
||||
label={`${row.metrics.margin.toFixed(0)}%`}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Wallet}
|
||||
title="Profitability"
|
||||
subtitle={dayjs(date).format('DD MMM YYYY')}
|
||||
actions={
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||||
aria-label="Report date"
|
||||
className="w-44"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={IndianRupee} value={currency(totals.revenue, '₹0')} label="Revenue" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard icon={Fuel} value={currency(totals.cost, '₹0')} label="Total cost" tone="warning" index={1} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={totals.net >= 0 ? TrendingUp : TrendingDown}
|
||||
value={currency(totals.net, '₹0')}
|
||||
label={`Net across ${number(totals.orders, '0')} orders`}
|
||||
tone={totals.net >= 0 ? 'success' : 'navy'}
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard icon={Percent} value={`${margin.toFixed(0)}%`} label="Margin" tone="accent" index={3} loading={isLoading} />
|
||||
</Grid>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={enriched}
|
||||
getRowId={(row) => row.id}
|
||||
loading={isLoading && collected.length === 0}
|
||||
refreshing={isFetching && collected.length > 0}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'net', direction: 'desc' }}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title={`No deliveries on ${dayjs(date).format('DD MMM YYYY')}`}
|
||||
description="Pick a day with dispatched orders to see how it performed."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<p className="text-caption leading-relaxed text-ink-3">
|
||||
Revenue: ₹{BASE_REVENUE} for the first {BASE_KM_LIMIT} km, then ₹{EXTRA_RATE_KM}/km. Cost: ₹
|
||||
{VARIABLE_RATE_KM}/km fuel and wear, plus ₹{FIXED_COST_PER_SLOT.toFixed(2)} per slot worked (capped at three a
|
||||
day). Distance is the straight line between pickup and drop — the road distance is higher, so these margins
|
||||
are the conservative read.
|
||||
</p>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
220
src/pages/doormile/reports/RidersSummary.jsx
Normal file
220
src/pages/doormile/reports/RidersSummary.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Bike, CheckCircle2, FileSpreadsheet, IndianRupee, Package } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useAppLocations, useRidersSummary } from '@/lib/doormileHooks';
|
||||
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Riders summary — per-rider KPI counts for a date range.
|
||||
*
|
||||
* Backed by `/admin/milers/summary`, whose per-row field names are not
|
||||
* documented. Each column reads defensively and falls back to a dash rather
|
||||
* than rendering `undefined`, so an unexpected shape degrades to blanks instead
|
||||
* of breaking the page.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function RidersSummary() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [zone, setZone] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const { data: summary = [], isLoading, isFetching } = useRidersSummary(
|
||||
zone === ALL ? '' : zone,
|
||||
range.from,
|
||||
range.to
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (summary || []).filter((row) => matchesQuery(row, ['displayname', 'phone', 'hubname'], debouncedSearch)),
|
||||
[summary, debouncedSearch]
|
||||
);
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
riders: acc.riders + 1,
|
||||
orders: acc.orders + (Number(row.assigned) || 0),
|
||||
delivered: acc.delivered + (Number(row.delivered) || 0),
|
||||
amount: acc.amount + (Number(row.ridercharges) || 0),
|
||||
}),
|
||||
{ riders: 0, orders: 0, delivered: 0, amount: 0 }
|
||||
),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'displayname',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{row.displayname || `Rider #${row.userid}`}</p>
|
||||
<p className="text-caption text-ink-3">{row.hubname || row.phone || '—'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assigned',
|
||||
header: 'Assigned',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.assigned) || 0,
|
||||
accessor: (row) => number(row.assigned, '0'),
|
||||
},
|
||||
{
|
||||
key: 'accepted',
|
||||
header: 'Accepted',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.accepted) || 0,
|
||||
accessor: (row) => number(row.accepted, '0'),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'delivered',
|
||||
header: 'Delivered',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.delivered) || 0,
|
||||
accessor: (row) => number(row.delivered, '0'),
|
||||
},
|
||||
{
|
||||
key: 'cancelled',
|
||||
header: 'Cancelled',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.cancelled) || 0,
|
||||
accessor: (row) => number(row.cancelled, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'riderkms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.riderkms) || 0,
|
||||
accessor: (row) => number(row.riderkms, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'ridercharges',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.ridercharges) || 0,
|
||||
accessor: (row) => currency(row.ridercharges),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Bike}
|
||||
title="Riders summary"
|
||||
subtitle={`${range.from === range.to ? range.from : `${range.from} → ${range.to}`}`}
|
||||
actions={
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.from}
|
||||
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
|
||||
aria-label="From date"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={range.to}
|
||||
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
|
||||
aria-label="To date"
|
||||
className="w-40"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Riders active" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders assigned" tone="navy" index={1} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={number(stats.delivered, '0')}
|
||||
label={`Delivered · ${percentOf(stats.delivered, stats.orders)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={IndianRupee}
|
||||
value={currency(stats.amount, '₹0')}
|
||||
label="Rider charges"
|
||||
tone="accent"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by rider, phone or hub"
|
||||
count={{ shown: rows.length, total: (summary || []).length, noun: 'riders' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, columns, `riders-summary-${range.from}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={zone} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-44">
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row, index) => row.milerprofileid ?? row.userid ?? index}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'delivered', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Bike}
|
||||
title="Nothing in this range"
|
||||
description="No rider activity was recorded for the dates selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
234
src/pages/doormile/riders/CreateRider.jsx
Normal file
234
src/pages/doormile/riders/CreateRider.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Bike } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useAppLocations, useCreateMiler, useHubs, useTenants } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Create a rider — a miler-app login plus the zone and hub they work out of.
|
||||
*
|
||||
* `configid` is deliberately not sent: the server defaults it to the miler-app
|
||||
* login partition, and overriding it creates an account the rider app cannot
|
||||
* sign in to.
|
||||
*/
|
||||
|
||||
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
||||
|
||||
const NO_HUB = 'none';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
displayname: '',
|
||||
authname: '',
|
||||
email: '',
|
||||
contactno: '',
|
||||
password: '',
|
||||
defaultvehicletype: 'Bike',
|
||||
tenantid: '',
|
||||
applocationid: '',
|
||||
hubid: '',
|
||||
};
|
||||
|
||||
export default function CreateRider() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* Doormile staff manage riders across every client and must choose one; a
|
||||
client login is scoped to its own tenant server-side, so the picker would
|
||||
only offer the answer the server already knows. */
|
||||
const loginTenantId = localStorage.getItem('tenantid');
|
||||
const isStaff = !loginTenantId || loginTenantId === '0';
|
||||
|
||||
const { data: tenants = [] } = useTenants({ enabled: isStaff });
|
||||
const { data: hubs = [] } = useHubs();
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [errors, setErrors] = useState({});
|
||||
const createMiler = useCreateMiler();
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nextErrors = {};
|
||||
if (!form.displayname.trim()) nextErrors.displayname = 'Enter the name shown to operators';
|
||||
if (!form.authname.trim()) nextErrors.authname = 'Enter the login name for the rider app';
|
||||
if (!/^\d{10}$/.test(form.contactno)) nextErrors.contactno = 'Enter a 10-digit mobile number';
|
||||
if (!form.email.trim()) nextErrors.email = 'Enter an email address';
|
||||
if (!form.password) nextErrors.password = 'Set a password';
|
||||
if (isStaff && !form.tenantid) nextErrors.tenantid = 'Choose the client this rider works for';
|
||||
if (!form.applocationid) nextErrors.applocationid = 'Choose the city';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createMiler.mutate(
|
||||
{
|
||||
authname: form.authname,
|
||||
displayname: form.displayname,
|
||||
email: form.email,
|
||||
contactno: form.contactno,
|
||||
password: form.password,
|
||||
tenantid: isStaff ? Number(form.tenantid) : Number(loginTenantId),
|
||||
defaultvehicletype: form.defaultvehicletype,
|
||||
applocationid: Number(form.applocationid),
|
||||
hubid: form.hubid ? Number(form.hubid) : undefined,
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/riders') }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Bike}
|
||||
title="New rider"
|
||||
subtitle="Creates a miler-app login and puts them on a zone"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/riders">Riders</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>New rider</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/riders')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<Surface radius="xl" padding="lg">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Display name" required error={errors.displayname}>
|
||||
<Input
|
||||
value={form.displayname}
|
||||
onChange={(e) => set('displayname')(e.target.value)}
|
||||
placeholder="Murali S"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Login name" required error={errors.authname} hint="What the rider types into the app">
|
||||
<Input value={form.authname} onChange={(e) => set('authname')(e.target.value)} placeholder="murali.s" />
|
||||
</Field>
|
||||
|
||||
<Field label="Mobile number" required error={errors.contactno}>
|
||||
<Input
|
||||
value={form.contactno}
|
||||
onChange={(e) => set('contactno')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="9876543210"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email" required error={errors.email}>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set('email')(e.target.value)}
|
||||
placeholder="murali@example.com"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Password" required error={errors.password}>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(e) => set('password')(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Default vehicle">
|
||||
<Select value={form.defaultvehicletype} onValueChange={set('defaultvehicletype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{isStaff && (
|
||||
<Field label="Client" required error={errors.tenantid}>
|
||||
<Select value={String(form.tenantid)} onValueChange={set('tenantid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="City" required error={errors.applocationid}>
|
||||
<Select value={String(form.applocationid)} onValueChange={set('applocationid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a city" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locations || [])
|
||||
.filter((location) => location.applocationid)
|
||||
.map((location) => (
|
||||
<SelectItem key={location.applocationid} value={String(location.applocationid)}>
|
||||
{location.locationname || `Location #${location.applocationid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Hub" hint="Optional — where the rider starts their shift">
|
||||
<Select
|
||||
value={form.hubid === '' ? NO_HUB : String(form.hubid)}
|
||||
onValueChange={(value) => set('hubid')(value === NO_HUB ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_HUB}>No hub</SelectItem>
|
||||
{(hubs || []).map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/riders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={createMiler.isPending}>
|
||||
Create rider
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
191
src/pages/doormile/riders/EditRider.jsx
Normal file
191
src/pages/doormile/riders/EditRider.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Bike } from 'lucide-react';
|
||||
import {
|
||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,
|
||||
SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useAppLocations, useMiler, useTenants, useUpdateMiler } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Edit a rider.
|
||||
*
|
||||
* Reached from the Riders table, which passes the row along in navigation
|
||||
* state; the record is then re-fetched by `milerprofileid`, because every
|
||||
* `/admin/milers/:id` route keys on that and 404s on the `userid` the same row
|
||||
* also carries.
|
||||
*
|
||||
* Only identity, contact, client and zone are writable. `PUT /admin/milers/:id`
|
||||
* documents no fields for bank details, vehicle settings or shift times, so
|
||||
* those are not offered here rather than shown as inputs that quietly discard
|
||||
* what is typed into them. A miler has a single `displayname` — there is no
|
||||
* first/last split anywhere on this resource.
|
||||
*/
|
||||
|
||||
const NO_TENANT = 'none';
|
||||
|
||||
export default function EditRider() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const passed = location.state?.rider;
|
||||
const milerProfileId = passed?.milerprofileid;
|
||||
|
||||
const { data: rider, isLoading } = useMiler(milerProfileId);
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const updateMiler = useUpdateMiler();
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
|
||||
/* Seeded from the row that was clicked so the fields are populated on the
|
||||
first paint, then reconciled with the fetched record when it lands. */
|
||||
useEffect(() => {
|
||||
const source = rider || passed;
|
||||
if (!source) return;
|
||||
setForm({
|
||||
displayname: source.displayname || source.authname || '',
|
||||
phone: source.phone || source.contactno || '',
|
||||
email: source.email || '',
|
||||
tenantid: source.tenantid ?? '',
|
||||
applocationid: source.applocationid ?? '',
|
||||
});
|
||||
}, [rider, passed]);
|
||||
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
if (!milerProfileId) {
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader icon={Bike} title="Edit rider" />
|
||||
<Alert
|
||||
tone="warning"
|
||||
title="No rider selected"
|
||||
action={<Button onClick={() => navigate('/doormile/riders')}>Go to Riders</Button>}
|
||||
>
|
||||
Open a rider from the Riders table — this page needs the record that was clicked.
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
updateMiler.mutate(
|
||||
{
|
||||
id: milerProfileId,
|
||||
data: {
|
||||
displayname: form.displayname,
|
||||
contactno: form.phone,
|
||||
email: form.email,
|
||||
tenantid: form.tenantid === '' ? undefined : Number(form.tenantid),
|
||||
applocationid: form.applocationid === '' ? undefined : Number(form.applocationid),
|
||||
},
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/riders') }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Bike}
|
||||
title={form?.displayname || 'Edit rider'}
|
||||
subtitle="Identity, contact, client and zone"
|
||||
breadcrumb={
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/doormile/riders">Riders</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/doormile/riders')}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading && !form ? (
|
||||
<LoadingState message="Loading rider…" />
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<Surface radius="xl" padding="lg">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Display name">
|
||||
<Input value={form?.displayname || ''} onChange={(e) => set('displayname')(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Mobile number">
|
||||
<Input
|
||||
value={form?.phone || ''}
|
||||
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email">
|
||||
<Input type="email" value={form?.email || ''} onChange={(e) => set('email')(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Client">
|
||||
<Select
|
||||
value={form?.tenantid === '' ? NO_TENANT : String(form?.tenantid ?? '')}
|
||||
onValueChange={(value) => set('tenantid')(value === NO_TENANT ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TENANT}>No client</SelectItem>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="City">
|
||||
<Select
|
||||
value={String(form?.applocationid ?? '')}
|
||||
onValueChange={set('applocationid')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a city" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname || `Location #${entry.applocationid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
|
||||
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/riders')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={updateMiler.isPending}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
</form>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
449
src/pages/doormile/riders/Riders.jsx
Normal file
449
src/pages/doormile/riders/Riders.jsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, Pencil, Plus, Radio, Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
|
||||
Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
|
||||
useRiderSummaryCounts, useVehicles,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Riders — the milers who actually carry the parcels.
|
||||
*
|
||||
* Two identifiers matter here and they are not interchangeable: `userid` is
|
||||
* what a booking's assignment records and what `assign-miler` wants, while
|
||||
* `milerprofileid` is the primary key of this resource and what block, notify,
|
||||
* assign-vehicle and logs all key on. Passing one where the other belongs 404s.
|
||||
*
|
||||
* The rider-substitution feature from the source console is deliberately absent:
|
||||
* it has no backend endpoint at all, so there is nothing here to build against.
|
||||
*/
|
||||
|
||||
/** "Active" means neither offline nor blocked — the same rule the counts use. */
|
||||
const isActive = (rider) => !['offline', 'blocked'].includes(String(rider?.availabilitystatus || '').toLowerCase());
|
||||
|
||||
const riderName = (rider) => rider?.displayname || rider?.authname || '—';
|
||||
|
||||
/** A bare `HH:mm:ss` from the shift record, rendered as a clock time. */
|
||||
const shiftTime = (value) =>
|
||||
value ? dayjs(`${dayjs().format('YYYY-MM-DD')} ${value}`).format('hh:mm A') : '—';
|
||||
|
||||
const TABS = [
|
||||
{ value: 'all', label: 'All riders' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
export default function Riders() {
|
||||
const navigate = useNavigate();
|
||||
const { data: riders = [], isLoading, isFetching } = useMilers();
|
||||
const { data: summary } = useRiderSummaryCounts();
|
||||
const { data: vehicles = [] } = useVehicles();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [tab, setTab] = useState('all');
|
||||
|
||||
const [blockRow, setBlockRow] = useState(null);
|
||||
const [vehicleRow, setVehicleRow] = useState(null);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState('');
|
||||
const [notifyRow, setNotifyRow] = useState(null);
|
||||
const [notice, setNotice] = useState({ title: 'DoormileXpress', message: '' });
|
||||
const [logsRow, setLogsRow] = useState(null);
|
||||
|
||||
const blockMiler = useBlockMiler();
|
||||
const assignVehicle = useAssignMilerVehicle();
|
||||
const notifyMiler = useNotifyMiler();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const list = riders || [];
|
||||
return {
|
||||
all: list.length,
|
||||
active: list.filter(isActive).length,
|
||||
blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length,
|
||||
};
|
||||
}, [riders]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = riders || [];
|
||||
if (tab === 'active') list = list.filter(isActive);
|
||||
else if (tab === 'blocked') {
|
||||
list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked');
|
||||
}
|
||||
return list.filter((rider) =>
|
||||
matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch)
|
||||
);
|
||||
}, [riders, tab, debouncedSearch]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'rider',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
accessor: riderName,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={riderName(row)} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{riderName(row)}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{orDash(row.phone)} · #{row.userid}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
header: 'Base',
|
||||
sortValue: (row) => row.suburb || row.address || '',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
|
||||
{row.suburb || row.address || '—'}
|
||||
</p>
|
||||
{row.city ? <p className="text-caption text-ink-3">{row.city}</p> : null}
|
||||
</div>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'defaultvehicletype',
|
||||
header: 'Vehicle',
|
||||
sortable: true,
|
||||
accessor: (row) => orDash(row.defaultvehicletype),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'shift',
|
||||
header: 'Shift',
|
||||
sortValue: (row) => row.starttime || '',
|
||||
accessor: (row) => `${shiftTime(row.starttime)} – ${shiftTime(row.endtime)}`,
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'basefare',
|
||||
header: 'Base fare',
|
||||
sortValue: (row) => Number(row.basefare) || 0,
|
||||
accessor: (row) => currency(row.basefare),
|
||||
align: 'right',
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'availabilitystatus',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.availabilitystatus} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
label="Edit rider"
|
||||
icon={Pencil}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/doormile/riders/edit', { state: { rider: row } })}
|
||||
/>
|
||||
<IconButton
|
||||
label="Assign vehicle"
|
||||
icon={Car}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVehicleRow(row);
|
||||
setSelectedVehicle('');
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
label="Send a notification"
|
||||
icon={Bell}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNotifyRow(row);
|
||||
setNotice({ title: 'DoormileXpress', message: '' });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
label="Live logs"
|
||||
icon={Radio}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLogsRow(row)}
|
||||
/>
|
||||
<IconButton label="Block rider" icon={Ban} variant="ghost" size="sm" onClick={() => setBlockRow(row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Bike}
|
||||
title="Riders"
|
||||
subtitle="Who is on the road, what they are on, and where they are"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/doormile/riders/create')}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New rider
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={Users}
|
||||
value={summary?.total ?? counts.all}
|
||||
label="Total riders"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setTab('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={summary?.available ?? 0}
|
||||
label="Available now"
|
||||
tone="success"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Bike}
|
||||
value={summary?.onDelivery ?? 0}
|
||||
label="On delivery"
|
||||
tone="accent"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CircleSlash}
|
||||
value={summary?.inactive ?? 0}
|
||||
label="Offline or blocked"
|
||||
tone="neutral"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => setTab('blocked')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<Tabs
|
||||
tabs={TABS.map((entry) => ({ ...entry, count: counts[entry.value] }))}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, phone, id or city"
|
||||
className="w-72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length === (counts[tab] ?? counts.all)
|
||||
? `${counts[tab] ?? counts.all} riders`
|
||||
: `${rows.length} of ${counts[tab] ?? counts.all} riders`}
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.milerprofileid ?? row.userid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'rider', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
debouncedSearch ? undefined : (
|
||||
<EmptyState
|
||||
icon={Bike}
|
||||
title="No riders here"
|
||||
description="Onboard a rider to start assigning deliveries."
|
||||
action={{ label: 'New rider', icon: Plus, onClick: () => navigate('/doormile/riders/create') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(blockRow)}
|
||||
onOpenChange={(open) => !open && setBlockRow(null)}
|
||||
title="Block this rider?"
|
||||
description={`${riderName(blockRow || {})} will stop receiving assignments until they are unblocked.`}
|
||||
confirmLabel="Block rider"
|
||||
busy={blockMiler.isPending}
|
||||
onConfirm={() => {
|
||||
blockMiler.mutate(
|
||||
{ id: blockRow.milerprofileid, data: {} },
|
||||
{ onSettled: () => setBlockRow(null) }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(vehicleRow)}
|
||||
onOpenChange={(open) => !open && setVehicleRow(null)}
|
||||
title="Assign a vehicle"
|
||||
description={riderName(vehicleRow || {})}
|
||||
icon={Car}
|
||||
busy={assignVehicle.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setVehicleRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
assignVehicle.mutate(
|
||||
{ id: vehicleRow.milerprofileid, data: { vehicleid: Number(selectedVehicle) } },
|
||||
{ onSuccess: (res) => res?.success !== false && setVehicleRow(null) }
|
||||
)
|
||||
}
|
||||
disabled={!selectedVehicle || assignVehicle.isPending}
|
||||
>
|
||||
Assign vehicle
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Vehicle">
|
||||
<Select value={selectedVehicle} onValueChange={setSelectedVehicle}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a vehicle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vehicles || []).map((vehicle) => (
|
||||
<SelectItem key={vehicle.vehicleid} value={String(vehicle.vehicleid)}>
|
||||
{vehicle.vehicleno || `Vehicle #${vehicle.vehicleid}`}
|
||||
{vehicle.vehicletype ? ` · ${vehicle.vehicletype}` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(notifyRow)}
|
||||
onOpenChange={(open) => !open && setNotifyRow(null)}
|
||||
title="Notify this rider"
|
||||
description={`${riderName(notifyRow || {})} — delivered to the rider app as a push notification.`}
|
||||
icon={Bell}
|
||||
busy={notifyMiler.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setNotifyRow(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
notifyMiler.mutate(
|
||||
{
|
||||
milerProfileId: notifyRow.milerprofileid,
|
||||
title: notice.title,
|
||||
message: notice.message,
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setNotifyRow(null) }
|
||||
)
|
||||
}
|
||||
disabled={notifyMiler.isPending || !notice.message.trim()}
|
||||
>
|
||||
Send notification
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack space="default">
|
||||
<Field label="Title">
|
||||
<Input value={notice.title} onChange={(e) => setNotice({ ...notice, title: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Message">
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={notice.message}
|
||||
onChange={(e) => setNotice({ ...notice, message: e.target.value })}
|
||||
placeholder="Orders have been placed for delivery. Kindly accept and process deliveries."
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<RiderLogsDrawer rider={logsRow} onClose={() => setLogsRow(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rider's own ping history — position and status over time.
|
||||
*
|
||||
* A log entry has no documented field names, so each row renders whichever of
|
||||
* the plausible ones is present rather than assuming a shape that would render
|
||||
* a column of blanks against a real response.
|
||||
*/
|
||||
function RiderLogsDrawer({ rider, onClose }) {
|
||||
const { data: logs = [], isLoading } = useMilerLogs(rider?.milerprofileid, undefined, undefined, 50);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(rider)}
|
||||
onOpenChange={(next) => !next && onClose()}
|
||||
title={`Live logs · ${riderName(rider || {})}`}
|
||||
description="The most recent pings this rider's app has sent."
|
||||
icon={Radio}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-body-sm text-ink-3">Loading logs…</p>
|
||||
) : !logs || logs.length === 0 ? (
|
||||
<EmptyState icon={Radio} size="sm" title="No pings recorded" description="This rider has not reported a position yet." />
|
||||
) : (
|
||||
<Stack space="sm">
|
||||
{logs.map((log, index) => (
|
||||
<Surface key={log.logid ?? index} variant="subtle" padding="sm" radius="lg">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-ink-1">
|
||||
{orDash(log.status ?? log.availabilitystatus ?? log.event)}
|
||||
</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{log.latitude ?? log.currentlatitude ?? '—'}, {log.longitude ?? log.currentlongitude ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-caption text-ink-4">
|
||||
{formatDoormileTimestamp(log.logdate ?? log.lastpingat ?? log.createdat, 'DD MMM, hh:mm A')}
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
406
src/pages/doormile/tripsheets/Tripsheets.jsx
Normal file
406
src/pages/doormile/tripsheets/Tripsheets.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ArrowRight, ListPlus, PackageCheck, PlaneLanding, PlaneTakeoff, Plus, Truck } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard, Modal,
|
||||
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack,
|
||||
StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useAddTripsheetItem, useArriveTripsheet, useCreateTripsheet, useDispatchTripsheet,
|
||||
useHubs, useMilers, useTripsheets, useVehicles,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Tripsheets — hub-to-hub transport.
|
||||
*
|
||||
* A tripsheet is a vehicle running between two hubs with consignments loaded
|
||||
* onto it, and it moves through three states: created, dispatched, arrived.
|
||||
* Consignments are added by id rather than picked from a list because the
|
||||
* console has no reliable consignment-search endpoint — the id comes off the
|
||||
* label being scanned at the dock.
|
||||
*/
|
||||
|
||||
const EMPTY_FORM = { sourcehubid: '', destinationhubid: '', vehicleid: '', driveruserid: '' };
|
||||
const NO_DRIVER = 'none';
|
||||
|
||||
export default function Tripsheets() {
|
||||
const { data: tripsheets = [], isLoading, isFetching } = useTripsheets();
|
||||
const { data: hubs = [] } = useHubs();
|
||||
const { data: vehicles = [] } = useVehicles();
|
||||
const { data: milers = [] } = useMilers();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [itemTrip, setItemTrip] = useState(null);
|
||||
const [itemConsignmentId, setItemConsignmentId] = useState('');
|
||||
|
||||
const createTripsheet = useCreateTripsheet();
|
||||
const addItem = useAddTripsheetItem();
|
||||
const dispatchTrip = useDispatchTripsheet();
|
||||
const arriveTrip = useArriveTripsheet();
|
||||
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((hub) => [hub.hubid, hub])), [hubs]);
|
||||
const hubName = (id) => hubMap.get(id)?.hubname || (id ? `Hub #${id}` : '—');
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tripsheets.length;
|
||||
const status = (row) => String(row.status || '').toLowerCase();
|
||||
return {
|
||||
total,
|
||||
created: tripsheets.filter((row) => status(row).includes('creat')).length,
|
||||
dispatched: tripsheets.filter((row) => status(row).includes('dispatch')).length,
|
||||
arrived: tripsheets.filter((row) => status(row).includes('arriv')).length,
|
||||
};
|
||||
}, [tripsheets]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const status = (row) => String(row.status || '').toLowerCase();
|
||||
let list = tripsheets;
|
||||
if (activeFilter === 'created') list = list.filter((row) => status(row).includes('creat'));
|
||||
else if (activeFilter === 'dispatched') list = list.filter((row) => status(row).includes('dispatch'));
|
||||
else if (activeFilter === 'arrived') list = list.filter((row) => status(row).includes('arriv'));
|
||||
|
||||
return list.filter((row) =>
|
||||
matchesQuery(
|
||||
row,
|
||||
['tripsheetid', 'status', (r) => hubName(r.sourcehubid), (r) => hubName(r.destinationhubid)],
|
||||
debouncedSearch
|
||||
)
|
||||
);
|
||||
|
||||
}, [tripsheets, activeFilter, debouncedSearch, hubMap]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
const nextErrors = {};
|
||||
if (!form.sourcehubid) nextErrors.sourcehubid = 'Choose the source hub';
|
||||
if (!form.destinationhubid) nextErrors.destinationhubid = 'Choose the destination hub';
|
||||
if (form.sourcehubid && form.sourcehubid === form.destinationhubid) {
|
||||
nextErrors.destinationhubid = 'A trip has to end somewhere other than where it started';
|
||||
}
|
||||
if (!form.vehicleid) nextErrors.vehicleid = 'Choose a vehicle';
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length) return;
|
||||
|
||||
createTripsheet.mutate(
|
||||
{
|
||||
sourcehubid: Number(form.sourcehubid),
|
||||
destinationhubid: Number(form.destinationhubid),
|
||||
vehicleid: Number(form.vehicleid),
|
||||
driveruserid: form.driveruserid ? Number(form.driveruserid) : undefined,
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setDialogOpen(false) }
|
||||
);
|
||||
};
|
||||
|
||||
const submitItem = () => {
|
||||
if (!itemConsignmentId.trim()) return;
|
||||
addItem.mutate(
|
||||
{ id: itemTrip.tripsheetid, consignmentid: itemConsignmentId.trim() },
|
||||
{ onSuccess: (res) => res?.success !== false && setItemConsignmentId('') }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'tripsheetid',
|
||||
header: 'Tripsheet',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-brand-tint text-brand">
|
||||
<Truck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">#{orDash(row.tripsheetid)}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{row.vehicleid ? `Vehicle #${row.vehicleid}` : 'No vehicle'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'route',
|
||||
header: 'Route',
|
||||
sortValue: (row) => hubName(row.sourcehubid),
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2 min-w-0 text-body-sm">
|
||||
<span className="truncate font-medium text-ink-1">{hubName(row.sourcehubid)}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
|
||||
<span className="truncate text-ink-2">{hubName(row.destinationhubid)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
label="Add consignment"
|
||||
icon={ListPlus}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setItemTrip(row);
|
||||
setItemConsignmentId('');
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
label="Dispatch"
|
||||
icon={PlaneTakeoff}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => dispatchTrip.mutate(row.tripsheetid)}
|
||||
/>
|
||||
<IconButton
|
||||
label="Mark arrived"
|
||||
icon={PlaneLanding}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => arriveTrip.mutate(row.tripsheetid)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
[hubMap, dispatchTrip, arriveTrip]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Truck}
|
||||
title="Tripsheets"
|
||||
subtitle="Hub-to-hub transport and what is loaded on each run"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New tripsheet
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={Truck}
|
||||
value={stats.total}
|
||||
label="Total tripsheets"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ListPlus}
|
||||
value={stats.created}
|
||||
label="Loading"
|
||||
tone="neutral"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('created')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PlaneTakeoff}
|
||||
value={stats.dispatched}
|
||||
label={`Dispatched · ${percentOf(stats.dispatched, stats.total)}%`}
|
||||
tone="warning"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('dispatched')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PackageCheck}
|
||||
value={stats.arrived}
|
||||
label={`Arrived · ${percentOf(stats.arrived, stats.total)}%`}
|
||||
tone="success"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('arrived')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by tripsheet id, hub or status"
|
||||
count={{ shown: rows.length, total: tripsheets.length, noun: 'tripsheets' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.tripsheetid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'tripsheetid', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Truck}
|
||||
title="No tripsheets yet"
|
||||
description="Create a run between two hubs, then load consignments onto it."
|
||||
action={{ label: 'New tripsheet', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title="New tripsheet"
|
||||
description="A vehicle running between two hubs. Consignments are loaded after it is created."
|
||||
icon={Truck}
|
||||
size="lg"
|
||||
busy={createTripsheet.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={create} disabled={createTripsheet.isPending}>
|
||||
Create tripsheet
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Source hub" required error={errors.sourcehubid}>
|
||||
<Select value={String(form.sourcehubid)} onValueChange={set('sourcehubid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(hubs || []).map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Destination hub" required error={errors.destinationhubid}>
|
||||
<Select value={String(form.destinationhubid)} onValueChange={set('destinationhubid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(hubs || []).map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Vehicle" required error={errors.vehicleid}>
|
||||
<Select value={String(form.vehicleid)} onValueChange={set('vehicleid')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a vehicle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(vehicles || []).map((vehicle) => (
|
||||
<SelectItem key={vehicle.vehicleid} value={String(vehicle.vehicleid)}>
|
||||
{vehicle.vehicleno || `Vehicle #${vehicle.vehicleid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Driver" hint="Optional — can be assigned at the dock">
|
||||
<Select
|
||||
value={form.driveruserid === '' ? NO_DRIVER : String(form.driveruserid)}
|
||||
onValueChange={(value) => set('driveruserid')(value === NO_DRIVER ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No driver" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_DRIVER}>No driver</SelectItem>
|
||||
{(milers || []).map((miler) => (
|
||||
<SelectItem key={miler.userid} value={String(miler.userid)}>
|
||||
{miler.displayname || miler.authname || `Rider #${miler.userid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(itemTrip)}
|
||||
onOpenChange={(open) => !open && setItemTrip(null)}
|
||||
title="Load a consignment"
|
||||
description={`Tripsheet #${itemTrip?.tripsheetid ?? ''} — ${hubName(itemTrip?.sourcehubid)} → ${hubName(itemTrip?.destinationhubid)}`}
|
||||
icon={ListPlus}
|
||||
busy={addItem.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setItemTrip(null)}>
|
||||
Done
|
||||
</Button>
|
||||
<Button onClick={submitItem} disabled={addItem.isPending || !itemConsignmentId.trim()}>
|
||||
Add consignment
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label="Consignment id"
|
||||
hint="The dialog stays open after each add, so a whole trolley can be loaded in one pass."
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={itemConsignmentId}
|
||||
onChange={(e) => setItemConsignmentId(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitItem();
|
||||
}
|
||||
}}
|
||||
placeholder="40"
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
413
src/pages/doormile/vehicles/Vehicles.jsx
Normal file
413
src/pages/doormile/vehicles/Vehicles.jsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { BatteryLow, BatteryFull, Car, CheckCircle2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button, ConfirmModal, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard,
|
||||
Modal, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useCreateVehicle, useDeleteVehicle, usePartners, useUpdateVehicle, useVehicles,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/** The fleet register — every vehicle a rider can be assigned to. */
|
||||
|
||||
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
||||
|
||||
/**
|
||||
* `status` is required and its enum is narrow: confirmed live that
|
||||
* "active"/"Active"/"inactive"/"Inactive"/"Unavailable"/"Maintenance"/"Offline"
|
||||
* are all rejected, and "Available" is the only value that writes. Only that one
|
||||
* is offered rather than guessing a second that fails on save.
|
||||
*/
|
||||
const STATUS_OPTIONS = [{ value: 'Available', label: 'Available' }];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
vehicleid: null,
|
||||
vehicleno: '',
|
||||
vehicletype: 'Bike',
|
||||
maxweight: '',
|
||||
maxvolume: '',
|
||||
partnerid: '',
|
||||
batterypercentage: '',
|
||||
status: 'Available',
|
||||
};
|
||||
|
||||
const NO_PARTNER = 'none';
|
||||
|
||||
export default function Vehicles() {
|
||||
const { data: vehicles = [], isLoading, isFetching } = useVehicles();
|
||||
const { data: partners = [] } = usePartners();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const createVehicle = useCreateVehicle();
|
||||
const updateVehicle = useUpdateVehicle();
|
||||
const deleteVehicle = useDeleteVehicle();
|
||||
|
||||
const partnerMap = useMemo(
|
||||
() => new Map((partners || []).map((partner) => [partner.partnerid, partner])),
|
||||
[partners]
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = vehicles.length;
|
||||
return {
|
||||
total,
|
||||
available: vehicles.filter((v) => String(v.status || '').toLowerCase() === 'available').length,
|
||||
/* A battery reading of 0 means "never reported", not "flat" — counting it
|
||||
as low would put every unreported vehicle in the urgent bucket. */
|
||||
lowBattery: vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length,
|
||||
fullBattery: vehicles.filter((v) => Number(v.batterypercentage) >= 80).length,
|
||||
};
|
||||
}, [vehicles]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = vehicles;
|
||||
if (activeFilter === 'available') {
|
||||
list = list.filter((v) => String(v.status || '').toLowerCase() === 'available');
|
||||
} else if (activeFilter === 'low') {
|
||||
list = list.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20);
|
||||
} else if (activeFilter === 'full') {
|
||||
list = list.filter((v) => Number(v.batterypercentage) >= 80);
|
||||
}
|
||||
return list.filter((v) => matchesQuery(v, ['vehicleno', 'vehicletype', 'status'], debouncedSearch));
|
||||
}, [vehicles, activeFilter, debouncedSearch]);
|
||||
|
||||
const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key));
|
||||
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
vehicleid: row.vehicleid,
|
||||
vehicleno: row.vehicleno || '',
|
||||
vehicletype: row.vehicletype || 'Bike',
|
||||
maxweight: row.maxweight ?? '',
|
||||
maxvolume: row.maxvolume ?? '',
|
||||
partnerid: row.partnerid ?? '',
|
||||
batterypercentage: row.batterypercentage ?? '',
|
||||
status: row.status || 'Available',
|
||||
});
|
||||
setErrors({});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!form.vehicleno.trim()) {
|
||||
setErrors({ vehicleno: 'Enter the registration number' });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
const { vehicleid, partnerid, ...rest } = form;
|
||||
const data = {
|
||||
...rest,
|
||||
maxweight: Number(rest.maxweight) || 0,
|
||||
maxvolume: Number(rest.maxvolume) || 0,
|
||||
batterypercentage: Number(rest.batterypercentage) || 0,
|
||||
partnerid: partnerid === '' ? undefined : Number(partnerid),
|
||||
};
|
||||
|
||||
const mutation = vehicleid ? updateVehicle : createVehicle;
|
||||
const variables = vehicleid ? { id: vehicleid, data } : data;
|
||||
mutation.mutate(variables, { onSuccess: (res) => res?.success !== false && setDialogOpen(false) });
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'vehicleno',
|
||||
header: 'Vehicle',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-brand-tint text-brand">
|
||||
<Car className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.vehicleno)}</p>
|
||||
<p className="text-caption text-ink-3">{orDash(row.vehicletype)}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partner',
|
||||
header: 'Partner',
|
||||
sortable: true,
|
||||
accessor: (row) => partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : ''),
|
||||
cell: (row) => {
|
||||
const partner = partnerMap.get(row.partnerid);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-2">
|
||||
{partner?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}
|
||||
</p>
|
||||
{row.partnerid ? <p className="text-caption text-ink-3">ID #{row.partnerid}</p> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'specs',
|
||||
header: 'Capacity & battery',
|
||||
sortValue: (row) => Number(row.maxweight) || 0,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm text-ink-2">
|
||||
{row.maxweight != null && row.maxweight !== '' ? `${row.maxweight} kg max` : '—'}
|
||||
</p>
|
||||
{row.batterypercentage != null && row.batterypercentage !== '' ? (
|
||||
<p className="text-caption text-ink-3">{row.batterypercentage}% battery</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.status} dot size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton label="Edit vehicle" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||||
<IconButton
|
||||
label="Delete vehicle"
|
||||
icon={Trash2}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete(row)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[partnerMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Car}
|
||||
title="Vehicles"
|
||||
subtitle="The fleet register riders are assigned from"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New vehicle
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard
|
||||
icon={Car}
|
||||
value={stats.total}
|
||||
label="Total vehicles"
|
||||
tone="brand"
|
||||
index={0}
|
||||
loading={isLoading}
|
||||
onClick={() => setActiveFilter('all')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={stats.available}
|
||||
label={`Available · ${percentOf(stats.available, stats.total)}%`}
|
||||
tone="success"
|
||||
index={1}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('available')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={BatteryLow}
|
||||
value={stats.lowBattery}
|
||||
label="Under 20% battery"
|
||||
tone="warning"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('low')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={BatteryFull}
|
||||
value={stats.fullBattery}
|
||||
label="80% battery or more"
|
||||
tone="navy"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
onClick={() => toggleFilter('full')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by registration, type or status"
|
||||
count={{ shown: rows.length, total: vehicles.length, noun: 'vehicles' }}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row) => row.vehicleid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={10}
|
||||
defaultSort={{ key: 'vehicleno', direction: 'asc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
emptyState={
|
||||
debouncedSearch || activeFilter !== 'all' ? undefined : (
|
||||
<EmptyState
|
||||
icon={Car}
|
||||
title="No vehicles yet"
|
||||
description="Register the first vehicle so riders have something to be assigned to."
|
||||
action={{ label: 'New vehicle', icon: Plus, onClick: openCreate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={form.vehicleid ? 'Edit vehicle' : 'New vehicle'}
|
||||
icon={Car}
|
||||
size="lg"
|
||||
busy={createVehicle.isPending || updateVehicle.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={createVehicle.isPending || updateVehicle.isPending}>
|
||||
{form.vehicleid ? 'Save changes' : 'Create vehicle'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Registration number" required error={errors.vehicleno}>
|
||||
<Input
|
||||
value={form.vehicleno}
|
||||
onChange={(e) => set('vehicleno')(e.target.value)}
|
||||
placeholder="TN 37 AB 1234"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Vehicle type">
|
||||
<Select value={form.vehicletype} onValueChange={set('vehicletype')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Partner" hint="The fleet supplier this vehicle belongs to">
|
||||
<Select
|
||||
value={form.partnerid === '' ? NO_PARTNER : String(form.partnerid)}
|
||||
onValueChange={(value) => set('partnerid')(value === NO_PARTNER ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No partner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PARTNER}>No partner</SelectItem>
|
||||
{(partners || []).map((partner) => (
|
||||
<SelectItem key={partner.partnerid} value={String(partner.partnerid)}>
|
||||
{partner.partnername || `Partner #${partner.partnerid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status" hint="Available is the only value this endpoint accepts">
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Max weight (kg)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxweight}
|
||||
onChange={(e) => set('maxweight')(e.target.value)}
|
||||
placeholder="25"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Max volume (litres)">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.maxvolume}
|
||||
onChange={(e) => set('maxvolume')(e.target.value)}
|
||||
placeholder="60"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Battery %">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.batterypercentage}
|
||||
onChange={(e) => set('batterypercentage')(e.target.value)}
|
||||
placeholder="100"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
title="Delete this vehicle?"
|
||||
description={`"${pendingDelete?.vehicleno || ''}" will be removed from the fleet register.`}
|
||||
confirmLabel="Delete vehicle"
|
||||
busy={deleteVehicle.isPending}
|
||||
onConfirm={() => {
|
||||
deleteVehicle.mutate(pendingDelete.vehicleid, { onSettled: () => setPendingDelete(null) });
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user