348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
import React, { useMemo, useState } from 'react';
|
|
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
|
|
import { Grid } from '@astryxdesign/core/Grid';
|
|
import { HStack } from '@astryxdesign/core/HStack';
|
|
import { VStack } from '@astryxdesign/core/VStack';
|
|
import { 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 { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
|
import { Divider } from '@astryxdesign/core/Divider';
|
|
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 Loader from 'components/Loader';
|
|
import TableLoader from 'components/nearle_components/TableLoader';
|
|
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
|
import PageHeader from 'components/nearle_components/PageHeader';
|
|
import StatCard from 'components/nearle_components/StatCard';
|
|
import StatusBadge from 'components/nearle_components/StatusBadge';
|
|
import { PageShell, KpiGrid, FilterToolbar, DataCard } from 'components/nearle_components/PageLayout';
|
|
import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
|
|
import { DT, STATUS } from 'themes/dt/tokens';
|
|
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
|
|
|
const BRAND = DT.brand;
|
|
|
|
const HUB_TYPE_OPTIONS = [
|
|
{ value: 'sorting_center', label: 'Sorting Center' },
|
|
{ value: 'delivery_hub', label: 'Delivery Hub' }
|
|
];
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'Active', label: 'Active' },
|
|
{ value: 'Inactive', label: 'Inactive' }
|
|
];
|
|
|
|
const emptyForm = {
|
|
hubid: null,
|
|
hubname: '',
|
|
hubtype: 'delivery_hub',
|
|
applocationid: '',
|
|
contactno: '',
|
|
address: '',
|
|
latitude: '',
|
|
longitude: '',
|
|
pincode: '',
|
|
// Confirmed live: POST /admin/hubs rejects lowercase status ("active"
|
|
// fails, "Active" works) — Inactive was never confirmed to work even on
|
|
// create, so this only offers the confirmed-working value here.
|
|
status: 'Active'
|
|
};
|
|
|
|
const Hubs = () => {
|
|
const queryClient = useQueryClient();
|
|
const [searchword, setSearchword] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [form, setForm] = useState(emptyForm);
|
|
|
|
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
|
|
|
const rows = useMemo(() => {
|
|
if (!debouncedSearch) return hubs;
|
|
const q = debouncedSearch.toLowerCase().trim();
|
|
return hubs.filter((row) =>
|
|
[row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
|
|
);
|
|
}, [hubs, debouncedSearch]);
|
|
|
|
const stats = useMemo(() => {
|
|
const total = hubs.length;
|
|
const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').length;
|
|
const delivery = hubs.filter((h) => h.hubtype === 'delivery_hub').length;
|
|
const active = hubs.filter((h) => String(h.status || '').toLowerCase() === 'active').length;
|
|
return { total, sorting, delivery, active };
|
|
}, [hubs]);
|
|
|
|
const opentoast = (message, variant = 'error') =>
|
|
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (payload) => (payload.hubid ? updateHub(payload.hubid, payload.data) : createHub(payload.data)),
|
|
onSuccess: (res) => {
|
|
if (res.success) {
|
|
opentoast(form.hubid ? 'Hub updated' : 'Hub created', 'success');
|
|
queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
|
|
setDialogOpen(false);
|
|
} else {
|
|
opentoast(res.message || 'Failed to save hub');
|
|
}
|
|
},
|
|
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save hub')
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id) => deleteHub(id),
|
|
onSuccess: (res) => {
|
|
if (res.success) {
|
|
opentoast('Hub deleted', 'success');
|
|
queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
|
|
} else {
|
|
opentoast(res.message || 'Failed to delete hub');
|
|
}
|
|
},
|
|
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete hub')
|
|
});
|
|
|
|
const openCreate = () => {
|
|
setForm(emptyForm);
|
|
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'
|
|
});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleDelete = (row) => {
|
|
if (window.confirm(`Delete hub "${row.hubname}"?`)) deleteMutation.mutate(row.hubid);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!form.hubname || !form.applocationid) {
|
|
opentoast('Fill Hub Name and City (applocationid)');
|
|
return;
|
|
}
|
|
const { hubid, ...data } = form;
|
|
saveMutation.mutate({
|
|
hubid,
|
|
data: {
|
|
...data,
|
|
applocationid: Number(data.applocationid),
|
|
latitude: Number(data.latitude) || 0,
|
|
longitude: Number(data.longitude) || 0
|
|
}
|
|
});
|
|
};
|
|
|
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
|
|
|
return (
|
|
<>
|
|
{(isLoading || deleteMutation.isPending) && <Loader />}
|
|
|
|
<PageShell>
|
|
<PageHeader
|
|
title="Hubs"
|
|
subtitle="Live · Sorting Centers & Delivery Hubs"
|
|
live
|
|
action={<Button label="New Hub" variant="primary" icon={<MdAdd size={16} />} onClick={openCreate} />}
|
|
/>
|
|
|
|
<KpiGrid>
|
|
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
|
|
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color={STATUS.info} />
|
|
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color={STATUS.delivered} />
|
|
<StatCard title="Active Hubs" value={stats.active} icon={<MdOutlineCheckCircle size={20} />} color={STATUS.active} />
|
|
</KpiGrid>
|
|
|
|
<DataCard
|
|
toolbar={
|
|
<FilterToolbar max={2}>
|
|
<HStack vAlign="center">
|
|
<Text weight="bold">
|
|
{hubs.length} total · {rows.length} shown
|
|
</Text>
|
|
</HStack>
|
|
<HStack justify="end">
|
|
<VStack width={260} padding={0} gap={0}>
|
|
<DebounceSearchBar
|
|
value={searchword}
|
|
onChange={setSearchword}
|
|
onDebouncedChange={setDebouncedSearch}
|
|
placeholder="Search hubs (ctrl+k)"
|
|
/>
|
|
</VStack>
|
|
</HStack>
|
|
</FilterToolbar>
|
|
}
|
|
>
|
|
<TableScroll minWidth={960}>
|
|
<Table density="balanced" dividers="rows" hasHover>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>#</TableHeaderCell>
|
|
<TableHeaderCell>Hub Name</TableHeaderCell>
|
|
<TableHeaderCell>Type</TableHeaderCell>
|
|
<TableHeaderCell>City</TableHeaderCell>
|
|
<TableHeaderCell>Address</TableHeaderCell>
|
|
<TableHeaderCell>Pincode</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 hubs to show"
|
|
description="Create a hub to start routing consignments through it."
|
|
icon={<MdOutlineWarehouse size={28} />}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
rows.map((row, index) => (
|
|
<TableRow key={row.hubid || index}>
|
|
<TableCell>{index + 1}</TableCell>
|
|
<TableCell>
|
|
<Text weight="bold">{row.hubname}</Text>
|
|
</TableCell>
|
|
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell>
|
|
<TableCell>{row.applocationid ?? '—'}</TableCell>
|
|
<TableCell>{row.address || '—'}</TableCell>
|
|
<TableCell>{row.pincode || '—'}</TableCell>
|
|
<TableCell>{row.status ? <StatusBadge status={row.status} /> : '—'}</TableCell>
|
|
<TableCell>
|
|
<HStack gap={1} justify="end">
|
|
<IconButton
|
|
label="Edit hub"
|
|
tooltip="Edit hub"
|
|
size="sm"
|
|
variant="ghost"
|
|
icon={<MdEdit size={16} />}
|
|
onClick={() => openEdit(row)}
|
|
/>
|
|
<IconButton
|
|
label="Delete hub"
|
|
tooltip="Delete hub"
|
|
size="sm"
|
|
variant="destructive"
|
|
icon={<MdDeleteOutline size={16} />}
|
|
onClick={() => handleDelete(row)}
|
|
/>
|
|
</HStack>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableScroll>
|
|
</DataCard>
|
|
</PageShell>
|
|
|
|
<Dialog isOpen={dialogOpen} onOpenChange={(next) => !next && setDialogOpen(false)} width={680} purpose="form">
|
|
<Layout
|
|
header={
|
|
<DialogHeader
|
|
title={form.hubid ? 'Edit Hub' : 'New Hub'}
|
|
subtitle={form.hubid ? `Updating "${form.hubname || 'hub'}"` : 'Add a sorting center or delivery hub'}
|
|
onOpenChange={(next) => !next && setDialogOpen(false)}
|
|
startContent={
|
|
<AccentAvatar color={BRAND} selected size={36}>
|
|
<MdOutlineWarehouse size={18} />
|
|
</AccentAvatar>
|
|
}
|
|
/>
|
|
}
|
|
content={
|
|
<LayoutContent padding={4}>
|
|
<VStack gap={4} padding={0}>
|
|
<VStack gap={3} padding={0}>
|
|
<Text type="label" weight="semibold" color="secondary">
|
|
Hub Details
|
|
</Text>
|
|
<TextInput label="Hub Name" value={form.hubname} onChange={set('hubname')} isRequired />
|
|
<Grid columns={{ minWidth: 180, repeat: 'fit' }} gap={3}>
|
|
<Selector label="Type" options={HUB_TYPE_OPTIONS} value={form.hubtype} onChange={set('hubtype')} />
|
|
<Selector label="Status" options={STATUS_OPTIONS} value={form.status} onChange={set('status')} />
|
|
<TextInput label="Contact No" value={form.contactno} onChange={set('contactno')} isOptional />
|
|
</Grid>
|
|
</VStack>
|
|
|
|
<Divider />
|
|
|
|
<VStack gap={3} padding={0}>
|
|
<Text type="label" weight="semibold" color="secondary">
|
|
Location
|
|
</Text>
|
|
<TextInput label="Address" value={form.address} onChange={set('address')} isOptional />
|
|
<NumberInput
|
|
label="Location ID (applocationid)"
|
|
value={form.applocationid === '' ? undefined : Number(form.applocationid)}
|
|
onChange={(v) => set('applocationid')(v ?? '')}
|
|
description="Read the correct id from GET /admin/hubs rather than guessing"
|
|
isRequired
|
|
/>
|
|
<Grid columns={{ minWidth: 160, repeat: 'fit' }} gap={3}>
|
|
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} isOptional />
|
|
<NumberInput
|
|
label="Latitude"
|
|
value={form.latitude === '' ? undefined : Number(form.latitude)}
|
|
onChange={(v) => set('latitude')(v ?? '')}
|
|
isOptional
|
|
/>
|
|
<NumberInput
|
|
label="Longitude"
|
|
value={form.longitude === '' ? undefined : Number(form.longitude)}
|
|
onChange={(v) => set('longitude')(v ?? '')}
|
|
isOptional
|
|
/>
|
|
</Grid>
|
|
</VStack>
|
|
</VStack>
|
|
</LayoutContent>
|
|
}
|
|
footer={
|
|
<LayoutFooter hasDivider>
|
|
<HStack gap={2} justify="end">
|
|
<Button label="Cancel" variant="secondary" onClick={() => setDialogOpen(false)} isDisabled={saveMutation.isPending} />
|
|
<Button
|
|
label={form.hubid ? 'Save Changes' : 'Create Hub'}
|
|
variant="primary"
|
|
onClick={handleSave}
|
|
isLoading={saveMutation.isPending}
|
|
/>
|
|
</HStack>
|
|
</LayoutFooter>
|
|
}
|
|
/>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Hubs;
|