310 lines
14 KiB
JavaScript
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;
|