updates on the ui changes and changed into doormile
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user