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

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;