Files
doormilxpress_astryx/src/pages/doormile/competitiveIntel/CompetitiveIntel.jsx

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>
);
}