updates on the ui changes and changed into doormile
This commit is contained in:
324
src/pages/doormile/competitiveIntel/CompetitiveIntel.jsx
Normal file
324
src/pages/doormile/competitiveIntel/CompetitiveIntel.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, Pencil, Plus, Radar, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button, ConfirmModal, DataTable, EmptyState, Field, IconButton, Input, Modal,
|
||||
PageHeader, 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">
|
||||
<PageHeader
|
||||
icon={Radar}
|
||||
title="Competitive intel"
|
||||
subtitle="What the other carriers in each area charge"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> {onBranches ? 'New branch' : 'New rate'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack space="sm">
|
||||
<Tabs tabs={TABS} value={tab} onChange={changeTab} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user