318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { Building2, Pencil, Plus, Radar, Trash2 } from 'lucide-react';
|
|
import {
|
|
Button, ConfirmModal, DataTable, EmptyState, Field, IconButton, Input, Modal, Pagination, Stack, Surface, Tabs,
|
|
} from '@/components/ds';
|
|
import {
|
|
useCarrierPricing, useCompetitorBranches, useCreateCarrierPricing,
|
|
useCreateCompetitorBranch, useDeleteCarrierPricing, useDeleteCompetitorBranch,
|
|
useUpdateCarrierPricing, useUpdateCompetitorBranch,
|
|
} from '@/lib/doormileHooks';
|
|
import { orDash } from '@/lib/doormileFormat';
|
|
|
|
/**
|
|
* Competitive intelligence — what the other carriers in each area charge.
|
|
*
|
|
* Two resources behind one page: competitor branches, and the carrier rate
|
|
* cards collected against them. Both paginate on `page`/`limit` rather than the
|
|
* `pageno`/`pagesize` convention the rest of the API uses, and both return far
|
|
* more rows than one page holds — so the pager here is server-side, not a slice
|
|
* of an already-complete list.
|
|
*
|
|
* Rate fields are free text on purpose. Real values include "600", "25-35",
|
|
* "Not Answered" and "." — casting them to numbers would discard most of the
|
|
* data that was actually collected.
|
|
*/
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const TABS = [
|
|
{ value: 'branches', label: 'Competitor branches' },
|
|
{ value: 'pricing', label: 'Carrier pricing' },
|
|
];
|
|
|
|
const EMPTY_BRANCH = {
|
|
id: null,
|
|
company: '',
|
|
area: '',
|
|
phone: '',
|
|
address: '',
|
|
pincodes: '',
|
|
rate_per_kg: '',
|
|
offers_pickup: '',
|
|
offers_drop: '',
|
|
time_in_days: '',
|
|
packing_charge: '',
|
|
};
|
|
|
|
const EMPTY_CARRIER = {
|
|
id: null,
|
|
company: '',
|
|
weight_slab: '',
|
|
service_type: '',
|
|
zone: '',
|
|
rate: '',
|
|
delivery_time: '',
|
|
};
|
|
|
|
export default function CompetitiveIntel() {
|
|
const [tab, setTab] = useState('branches');
|
|
const [page, setPage] = useState(1);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [branchForm, setBranchForm] = useState(EMPTY_BRANCH);
|
|
const [carrierForm, setCarrierForm] = useState(EMPTY_CARRIER);
|
|
const [errors, setErrors] = useState({});
|
|
const [pendingDelete, setPendingDelete] = useState(null);
|
|
|
|
const branchesQuery = useCompetitorBranches(page, PAGE_SIZE, { enabled: tab === 'branches' });
|
|
const carrierQuery = useCarrierPricing(page, PAGE_SIZE, { enabled: tab === 'pricing' });
|
|
|
|
const createBranch = useCreateCompetitorBranch();
|
|
const updateBranch = useUpdateCompetitorBranch();
|
|
const deleteBranch = useDeleteCompetitorBranch();
|
|
const createCarrier = useCreateCarrierPricing();
|
|
const updateCarrier = useUpdateCarrierPricing();
|
|
const deleteCarrier = useDeleteCarrierPricing();
|
|
|
|
const onBranches = tab === 'branches';
|
|
const response = onBranches ? branchesQuery.data : carrierQuery.data;
|
|
const rows = response?.data || [];
|
|
const total = Number(response?.total) || 0;
|
|
const isLoading = onBranches ? branchesQuery.isLoading : carrierQuery.isLoading;
|
|
const isFetching = onBranches ? branchesQuery.isFetching : carrierQuery.isFetching;
|
|
|
|
const changeTab = (next) => {
|
|
setTab(next);
|
|
setPage(1);
|
|
};
|
|
|
|
const setBranch = (key) => (value) => setBranchForm((current) => ({ ...current, [key]: value }));
|
|
const setCarrier = (key) => (value) => setCarrierForm((current) => ({ ...current, [key]: value }));
|
|
|
|
const openCreate = () => {
|
|
setBranchForm(EMPTY_BRANCH);
|
|
setCarrierForm(EMPTY_CARRIER);
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const openEdit = (row) => {
|
|
if (onBranches) setBranchForm({ ...EMPTY_BRANCH, ...row });
|
|
else setCarrierForm({ ...EMPTY_CARRIER, ...row });
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const save = () => {
|
|
const close = { onSuccess: (res) => res?.success !== false && setDialogOpen(false) };
|
|
|
|
if (onBranches) {
|
|
if (!branchForm.company.trim()) {
|
|
setErrors({ company: 'Enter the company name' });
|
|
return;
|
|
}
|
|
setErrors({});
|
|
const { id, ...data } = branchForm;
|
|
if (id) updateBranch.mutate({ id, data }, close);
|
|
else createBranch.mutate(data, close);
|
|
return;
|
|
}
|
|
|
|
const nextErrors = {};
|
|
if (!carrierForm.company.trim()) nextErrors.company = 'Enter the company name';
|
|
if (!carrierForm.weight_slab.trim()) nextErrors.weight_slab = 'Enter the weight slab';
|
|
setErrors(nextErrors);
|
|
if (Object.keys(nextErrors).length) return;
|
|
|
|
const { id, ...data } = carrierForm;
|
|
if (id) updateCarrier.mutate({ id, data }, close);
|
|
else createCarrier.mutate(data, close);
|
|
};
|
|
|
|
const rowActions = (row) => (
|
|
<div className="flex justify-end gap-1">
|
|
<IconButton label="Edit" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
|
<IconButton label="Delete" icon={Trash2} variant="ghost" size="sm" onClick={() => setPendingDelete(row)} />
|
|
</div>
|
|
);
|
|
|
|
const branchColumns = [
|
|
{
|
|
key: 'company',
|
|
header: 'Company',
|
|
sortable: true,
|
|
cell: (row) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.company)}</p>
|
|
<p className="truncate text-caption text-ink-3">{orDash(row.area)}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'phone', header: 'Phone', accessor: (row) => orDash(row.phone), hideBelow: 'md' },
|
|
{
|
|
key: 'address',
|
|
header: 'Address',
|
|
cell: (row) => (
|
|
<p className="truncate text-body-sm text-ink-2" title={row.address || ''}>
|
|
{orDash(row.address)}
|
|
</p>
|
|
),
|
|
hideBelow: 'lg',
|
|
},
|
|
{ key: 'pincodes', header: 'Pincodes', accessor: (row) => orDash(row.pincodes), hideBelow: 'lg' },
|
|
{ key: 'rate_per_kg', header: 'Rate / kg', sortable: true, accessor: (row) => orDash(row.rate_per_kg), align: 'right' },
|
|
{ key: 'actions', header: 'Actions', align: 'right', cell: rowActions },
|
|
];
|
|
|
|
const carrierColumns = [
|
|
{ key: 'company', header: 'Company', sortable: true, accessor: (row) => orDash(row.company) },
|
|
{ key: 'weight_slab', header: 'Weight slab', sortable: true, accessor: (row) => orDash(row.weight_slab) },
|
|
{ key: 'service_type', header: 'Service', accessor: (row) => orDash(row.service_type), hideBelow: 'md' },
|
|
{ key: 'zone', header: 'Zone', sortable: true, accessor: (row) => orDash(row.zone) },
|
|
{ key: 'rate', header: 'Rate', accessor: (row) => orDash(row.rate), align: 'right' },
|
|
{ key: 'delivery_time', header: 'Delivery time', accessor: (row) => orDash(row.delivery_time), hideBelow: 'lg' },
|
|
{ key: 'actions', header: 'Actions', align: 'right', cell: rowActions },
|
|
];
|
|
|
|
const busy =
|
|
createBranch.isPending || updateBranch.isPending || createCarrier.isPending || updateCarrier.isPending;
|
|
|
|
return (
|
|
<Stack space="lg">
|
|
<Stack space="sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 w-full">
|
|
<Tabs tabs={TABS} value={tab} onChange={changeTab} />
|
|
<Button onClick={openCreate}>
|
|
<Plus className="mr-1.5 h-4 w-4" /> {onBranches ? 'New branch' : 'New rate'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-caption text-ink-3">
|
|
{total ? `${total} records · page ${page} of ${Math.max(1, Math.ceil(total / PAGE_SIZE))}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={onBranches ? branchColumns : carrierColumns}
|
|
rows={rows}
|
|
getRowId={(row, index) => row.id ?? index}
|
|
loading={isLoading}
|
|
refreshing={isFetching && !isLoading}
|
|
emptyState={
|
|
<EmptyState
|
|
icon={onBranches ? Building2 : Radar}
|
|
title={onBranches ? 'No competitor branches recorded' : 'No carrier rates recorded'}
|
|
description="Add what you know about the competition in each area."
|
|
action={{ label: onBranches ? 'New branch' : 'New rate', icon: Plus, onClick: openCreate }}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{/* Server-side: each page is its own request, so this is not a slice of
|
|
an already-complete list. */}
|
|
{total > PAGE_SIZE && (
|
|
<Surface variant="subtle" padding="sm" radius="lg">
|
|
<Pagination
|
|
page={page}
|
|
pageCount={Math.max(1, Math.ceil(total / PAGE_SIZE))}
|
|
pageSize={PAGE_SIZE}
|
|
totalItems={total}
|
|
onPageChange={setPage}
|
|
/>
|
|
</Surface>
|
|
)}
|
|
</Stack>
|
|
|
|
<Modal
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
title={onBranches ? 'Competitor branch' : 'Carrier rate'}
|
|
description="Rates are free text — record them exactly as they were quoted."
|
|
icon={Radar}
|
|
size="lg"
|
|
busy={busy}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={save} disabled={busy}>
|
|
Save
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{onBranches ? (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<Field label="Company" required error={errors.company}>
|
|
<Input value={branchForm.company} onChange={(e) => setBranch('company')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Area">
|
|
<Input value={branchForm.area} onChange={(e) => setBranch('area')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Phone">
|
|
<Input value={branchForm.phone} onChange={(e) => setBranch('phone')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Pincodes" hint="Comma-separated">
|
|
<Input value={branchForm.pincodes} onChange={(e) => setBranch('pincodes')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Address" className="sm:col-span-2">
|
|
<Input value={branchForm.address} onChange={(e) => setBranch('address')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Rate per kg" hint='As quoted — "600", "25-35" and "Not answered" are all valid'>
|
|
<Input value={branchForm.rate_per_kg} onChange={(e) => setBranch('rate_per_kg')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Packing charge">
|
|
<Input value={branchForm.packing_charge} onChange={(e) => setBranch('packing_charge')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Offers pickup">
|
|
<Input value={branchForm.offers_pickup} onChange={(e) => setBranch('offers_pickup')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Offers drop">
|
|
<Input value={branchForm.offers_drop} onChange={(e) => setBranch('offers_drop')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Time in days">
|
|
<Input value={branchForm.time_in_days} onChange={(e) => setBranch('time_in_days')(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<Field label="Company" required error={errors.company}>
|
|
<Input value={carrierForm.company} onChange={(e) => setCarrier('company')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Weight slab" required error={errors.weight_slab}>
|
|
<Input value={carrierForm.weight_slab} onChange={(e) => setCarrier('weight_slab')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Service type">
|
|
<Input value={carrierForm.service_type} onChange={(e) => setCarrier('service_type')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Zone">
|
|
<Input value={carrierForm.zone} onChange={(e) => setCarrier('zone')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Rate">
|
|
<Input value={carrierForm.rate} onChange={(e) => setCarrier('rate')(e.target.value)} />
|
|
</Field>
|
|
<Field label="Delivery time">
|
|
<Input value={carrierForm.delivery_time} onChange={(e) => setCarrier('delivery_time')(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
<ConfirmModal
|
|
open={Boolean(pendingDelete)}
|
|
onOpenChange={(open) => !open && setPendingDelete(null)}
|
|
title="Delete this record?"
|
|
description={`${pendingDelete?.company || 'This record'} will be removed from the competitive register.`}
|
|
confirmLabel="Delete"
|
|
busy={deleteBranch.isPending || deleteCarrier.isPending}
|
|
onConfirm={() => {
|
|
const mutation = onBranches ? deleteBranch : deleteCarrier;
|
|
mutation.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
|
|
}}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|