309 lines
11 KiB
JavaScript
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;
|