Files
Doormilexpress_app/src/pages/nearle/dispatch/Preview.js

309 lines
11 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { HiOutlineArrowLeft } from 'react-icons/hi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Stack } from '@astryxdesign/core/Stack';
import { Heading } from '@astryxdesign/core/Heading';
import { finalCreatedeliveries, notifyRider } from '../api/api';
import { OpenToast } from 'components/nearle_components/OpenToast';
import CircularLoader from 'components/nearle_components/CircularLoader';
import Dispatch from './Dispatch';
import { BRAND, DT } from '../_shared/ordersDesign';
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
const extractRiders = (previewData) => {
if (!previewData) return [];
const map = new Map();
// De-dupe by orderid across the whole tree. A rider can legitimately appear
// in multiple zones (one per delivery suburb), so the same rider_id is
// visited more than once. Without this guard, any stale copy left behind
// by applyReconcileResponse gets concatenated into the rider's orders and
// the same orderid is sent twice to /deliveries/createdeliveries.
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
}
const entry = map.get(key);
(orders || []).forEach((o) => {
const oid = o?.orderid != null ? String(o.orderid) : null;
if (oid) {
if (seenOrderIds.has(oid)) return;
seenOrderIds.add(oid);
}
entry.orders.push(o);
});
if (!entry.rider_name && riderName) entry.rider_name = riderName;
};
if (Array.isArray(previewData.zones) && previewData.zones.length) {
previewData.zones.forEach((z) => {
(z.riders || []).forEach((r) => {
const id = r.rider_id ?? r.userid;
const name = r.rider_name || r.username || `Rider ${id}`;
push(id, name, r.orders);
});
});
} else if (Array.isArray(previewData.details)) {
previewData.details.forEach((o) => {
const id = o.rider_id ?? o.userid;
const name = o.rider_name || o.ridername || `Rider ${id}`;
push(id, name, [o]);
});
}
return Array.from(map.values());
};
// Reverse of extractRiders — flatten rider-grouped list into a details-style
// array (used as the Assign Orders payload).
const flattenRiders = (riders) => {
const out = [];
riders.forEach((r) => {
// Go backend types Deliveries.userid as int — coerce here so any
// upstream string (AI response, riders API, change-rider edit) gets
// normalised before the JSON body is built.
const ridNum = Number(r.rider_id);
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
(r.orders || []).forEach((o) => {
out.push({
...o,
rider_id: rid,
userid: rid,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
return out;
};
const Preview = () => {
const navigate = useNavigate();
const location = useLocation();
const stateData = location.state || {};
// SINGLE SOURCE OF TRUTH: every Change Rider / Reconcile / Re-Assign goes
// through this state. The Dispatch tab renders from it, the Reconcile tab
// derives its rider list from it, and Assign Orders sends a flattened copy
// of it to the API.
const [dispatchPreviewData] = useState(stateData.dispatchPreviewData || null);
// The AI response arrives via location.state, which the browser stores in
// history.state and persists across reloads. That means a reload of
// /dispatch/preview would re-hydrate the stale snapshot — including any
// pending edits the user thought they discarded. Bounce to /orders when
// there's no fresh response, and wipe the history snapshot once consumed
// so a later reload / back-forward also bounces instead of re-using it.
useEffect(() => {
if (!stateData.dispatchPreviewData) {
navigate('/nearle/orders', { replace: true });
return;
}
if (typeof window !== 'undefined' && window.history?.state) {
window.history.replaceState({ ...window.history.state, usr: null }, '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [, setCsvExportData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const rider = stateData.rider || null;
// Derived: rider list for the Reconcile tab. Recomputes whenever the cache
// (dispatchPreviewData) changes — so Reconcile reflects here without a separate state.
const reconcileRiders = useMemo(() => extractRiders(dispatchPreviewData), [dispatchPreviewData]);
// Derived: flat orders list used for the Assign Orders payload + CSV export.
// Always reflects the latest cache state.
const finaldeliveryList = useMemo(() => {
const flat = flattenRiders(reconcileRiders);
if (flat.length) return computeDeliveryAmounts(flat);
if (Array.isArray(dispatchPreviewData?.details)) {
return computeDeliveryAmounts(dispatchPreviewData.details);
}
return [];
}, [reconcileRiders, dispatchPreviewData]);
useEffect(() => {
const filtered = finaldeliveryList.map((item) => ({
zone_name: item.zone_name,
ordernotes: item.ordernotes,
rider: item.rider,
step: item.step,
ordertype: item.ordertype,
orderamount: item.orderamount,
riderkms: item.riderkms,
cumulativekms: item.cumulativekms,
baseprice: item.baseprice,
minkm: item.minkm,
priceperkm: item.priceperkm,
kms: item.kms,
actualkms: item.actualkms,
rider_charge: item.rider_charge,
deliveryamt: item.deliveryamt,
deliverycharges: item.deliverycharges,
profit: item.profit
}));
setCsvExportData(filtered);
}, [finaldeliveryList]);
const notifyRiderMutation = useMutation({
mutationFn: notifyRider,
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
onError: (error) => OpenToast(error.message, 'error', 2000)
});
const createFinalDeliveryMutation = useMutation({
mutationFn: finalCreatedeliveries,
onSuccess: () => {
OpenToast('Delivery Created Successfully', 'success', 2000);
setIsLoading(false);
if (rider?.userfcmtoken) notifyRiderMutation.mutate(rider.userfcmtoken);
navigate('/nearle/deliveries');
},
onError: (error) => {
OpenToast(error.message, 'error', 4000);
setIsLoading(false);
},
onSettled: () => setIsLoading(false)
});
const handleFinalCreateDelivery = () => {
if (!finaldeliveryList?.length) {
OpenToast('No deliveries to assign', 'error', 3000);
return;
}
setIsLoading(true);
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
};
return (
<div
className="dispatch-preview-shell"
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - var(--appshell-header-height, 64px))',
overflow: 'hidden',
position: 'relative'
}}
>
{isLoading && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1301,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularLoader />
</div>
)}
<div
style={{
padding: '12px 20px',
borderBottom: `1px solid ${DT.borderSubtle}`,
background: 'linear-gradient(135deg, rgba(192, 18, 39,0.06) 0%, rgba(210, 84, 99,0.06) 100%)',
flexShrink: 0
}}
>
<Stack direction="horizontal" vAlign="center" justify="between">
<Stack direction="horizontal" vAlign="center" gap={1.5}>
<Tooltip content="Back to orders" placement="above">
<IconButton
label="Back to orders"
icon={<HiOutlineArrowLeft size={18} />}
variant="secondary"
onClick={() => navigate('/nearle/orders')}
style={{
backgroundColor: '#ffffff',
border: `1px solid ${DT.borderSubtle}`,
color: BRAND,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
}}
/>
</Tooltip>
<Heading level={1} style={{ margin: 0, fontSize: 20, fontWeight: 700, color: DT.textPrimary }}>
Assign Orders
</Heading>
</Stack>
<Button
label="Assign Orders"
variant="primary"
onClick={handleFinalCreateDelivery}
style={{
borderRadius: 999,
padding: '7px 28px',
backgroundColor: BRAND,
color: '#ffffff',
fontWeight: 800,
fontSize: 13,
boxShadow: '0 4px 14px rgba(192, 18, 39, 0.25)'
}}
/>
</Stack>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{dispatchPreviewData && (
<Dispatch
key={dispatchPreviewData?.__cacheKey || JSON.stringify(reconcileRiders.length)}
data={dispatchPreviewData}
embedded
/>
)}
</div>
<style>{`
.dispatch-preview-shell {
margin-top: -24px;
margin-left: -24px;
margin-right: -24px;
}
@media (max-width: 480px) {
.dispatch-preview-shell {
margin-top: -16px;
margin-left: -16px;
margin-right: -16px;
}
}
`}</style>
</div>
);
};
// Mirrors the orders.js deliveryamt recalc — applied at render-time so the
// Assign payload always reflects the current cache without a useEffect.
function computeDeliveryAmounts(list) {
return list.map((item) => {
const cumulativeKms = Number(item.cumulativekms || 0);
const minKm = Number(item.minkm || 0);
const basePrice = Number(item.baseprice || 0);
const pricePerKm = Number(item.priceperkm || 0);
if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice };
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}
export default Preview;