Files
Doormilexpress_console/src/pages/nearle/vehicles/vehicles.js

310 lines
14 KiB
JavaScript

import React, { useMemo, useState } from 'react';
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatteryFull } from 'react-icons/md';
import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives';
const BRAND = DT.brand;
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
const emptyForm = {
vehicleid: null,
vehicleno: '',
vehicletype: 'Bike',
maxweight: '',
maxvolume: '',
partner: null,
batterypercentage: '',
// Confirmed live: POST /admin/vehicles rejects "active"/"Active"/
// "inactive"/"Inactive"/"Unavailable"/"Maintenance"/"Offline" — the only
// value that worked was "Available". Status is a required field with a
// narrow, still not fully mapped enum, so this only offers the one
// confirmed-working value rather than guessing a second one that fails.
status: 'Available'
};
const Vehicles = () => {
const queryClient = useQueryClient();
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]);
const rows = useMemo(() => {
if (!debouncedSearch) return vehicles;
const q = debouncedSearch.toLowerCase().trim();
return vehicles.filter((row) => [row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
}, [vehicles, debouncedSearch]);
const stats = useMemo(() => {
const total = vehicles.length;
// Only 'Available' is a confirmed-valid status on this resource (see the
// comment on emptyForm.status above) — no vehicle can ever have a status
// of 'active', so counting against that string always read 0.
const active = vehicles.filter((v) => String(v.status).toLowerCase() === 'available').length;
const lowBattery = vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length;
const fullBattery = vehicles.filter((v) => Number(v.batterypercentage) >= 80).length;
return { total, active, lowBattery, fullBattery };
}, [vehicles]);
const opentoast = (message, variant = 'error') =>
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const saveMutation = useMutation({
mutationFn: (payload) => (payload.vehicleid ? updateVehicle(payload.vehicleid, payload.data) : createVehicle(payload.data)),
onSuccess: (res) => {
if (res.success) {
opentoast(form.vehicleid ? 'Vehicle updated' : 'Vehicle created', 'success');
queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
setDialogOpen(false);
} else {
opentoast(res.message || 'Failed to save vehicle');
}
},
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save vehicle')
});
const deleteMutation = useMutation({
mutationFn: (id) => deleteVehicle(id),
onSuccess: (res) => {
if (res.success) {
opentoast('Vehicle deleted', 'success');
queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
} else {
opentoast(res.message || 'Failed to delete vehicle');
}
},
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete vehicle')
});
const openCreate = () => {
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (row) => {
setForm({
vehicleid: row.vehicleid,
vehicleno: row.vehicleno || '',
vehicletype: row.vehicletype || 'Bike',
maxweight: row.maxweight ?? '',
maxvolume: row.maxvolume ?? '',
partner: partnerMap.get(row.partnerid) || null,
batterypercentage: row.batterypercentage ?? '',
status: row.status || 'Available'
});
setDialogOpen(true);
};
const handleDelete = (row) => {
if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
};
const set = (key) => (value) => setForm((fm) => ({ ...fm, [key]: value }));
const handleSave = () => {
if (!form.vehicleno) {
opentoast('Fill Vehicle No');
return;
}
const { vehicleid, partner, ...rest } = form;
saveMutation.mutate({
vehicleid,
data: {
...rest,
maxweight: Number(rest.maxweight) || 0,
maxvolume: Number(rest.maxvolume) || 0,
batterypercentage: Number(rest.batterypercentage) || 0,
partnerid: partner?.partnerid
}
});
};
return (
<>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
<PageHeader
title="Vehicles"
subtitle="Live · Fleet Directory"
live
action={<Button label="New Vehicle" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
/>
<Grid columns={{ minWidth: 200, max: 4, repeat: 'fit' }} gap={3}>
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
<StatCard title="Available" value={stats.active} icon={<MdDirectionsCar size={20} />} color={STATUS.delivered} />
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color={STATUS.pending} />
<StatCard title="Full Battery" value={stats.fullBattery} icon={<MdBatteryFull size={20} />} color={STATUS.active} />
</Grid>
<VStack gap={0} padding={0} style={{ marginTop: 16 }}>
<Card padding={3} elevation="none" style={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, borderBottom: 'none' }}>
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<Text weight="bold">
{vehicles.length} total · {rows.length} shown
</Text>
<div style={{ width: 280, maxWidth: '100%' }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search vehicles (ctrl+k)"
/>
</div>
</HStack>
</Card>
<Card padding={0} elevation="none" style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0, overflow: 'hidden' }}>
<TableScroll minWidth={960}>
<Table density="balanced" dividers="rows" hasHover>
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Vehicle No</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Partner</TableHeaderCell>
<TableHeaderCell>Max Weight</TableHeaderCell>
<TableHeaderCell>Battery</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && <TableLoader rows={5} columns={8} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={8}>
<EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} />
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow key={row.vehicleid || index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Text weight="bold">{row.vehicleno}</Text>
</TableCell>
<TableCell>{row.vehicletype || '—'}</TableCell>
<TableCell>
{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}
</TableCell>
<TableCell>{row.maxweight ?? '—'}</TableCell>
<TableCell>{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</TableCell>
<TableCell>{row.status || '—'}</TableCell>
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Edit vehicle"
tooltip="Edit vehicle"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete vehicle"
tooltip="Delete vehicle"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
</Card>
</VStack>
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={640} purpose="form">
<DialogHeader title={form.vehicleid ? 'Edit Vehicle' : 'New Vehicle'} />
<VStack gap={4} padding={4}>
<Grid columns={{ minWidth: 240 }} gap={3}>
<TextInput label="Vehicle No" value={form.vehicleno} onChange={set('vehicleno')} isRequired />
<Selector label="Vehicle Type" options={VEHICLE_TYPE_OPTIONS} value={form.vehicletype} onChange={set('vehicletype')} />
<Selector
label="Partner"
placeholder="Choose partner"
hasSearch
hasClear
options={(partners || []).map((p) => ({ value: String(p.partnerid), label: p.partnername || `Partner #${p.partnerid}` }))}
value={form.partner ? String(form.partner.partnerid) : undefined}
onChange={(v) => set('partner')(v ? partnerMap.get(Number(v)) || null : null)}
/>
{/* Only "Available" is confirmed to work against POST /admin/vehicles
(Active/Inactive/Unavailable/Maintenance/Offline all rejected
live) — not offering a second option that's known to fail. On
edit this preserves whatever the vehicle's real status already is. */}
<TextInput
label="Status"
value={form.status}
onChange={set('status')}
isDisabled
disabledMessage="Only 'Available' is accepted by the API"
/>
</Grid>
<Grid columns={{ minWidth: 180 }} gap={3}>
<NumberInput
label="Max Weight (kg)"
value={form.maxweight === '' ? undefined : Number(form.maxweight)}
onChange={(v) => set('maxweight')(v ?? '')}
isOptional
/>
<NumberInput
label="Max Volume"
value={form.maxvolume === '' ? undefined : Number(form.maxvolume)}
onChange={(v) => set('maxvolume')(v ?? '')}
isOptional
/>
<NumberInput
label="Battery %"
value={form.batterypercentage === '' ? undefined : Number(form.batterypercentage)}
onChange={(v) => set('batterypercentage')(v ?? '')}
isOptional
/>
</Grid>
<HStack gap={2} justify="end">
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} />
<Button label={form.vehicleid ? 'Save Changes' : 'Create'} variant="primary" onClick={handleSave} />
</HStack>
</VStack>
</Dialog>
</>
);
};
export default Vehicles;