updates on the miler page and the create order calculations as well

This commit is contained in:
2026-10-07 17:18:42 +05:30
parent ff1b795292
commit 3d07ea8170
10 changed files with 489 additions and 99 deletions

View File

@@ -375,6 +375,12 @@ export const blockMiler = async (id, data) => {
return response.data; return response.data;
}; };
/** Lift a block: the rider can sign in again and is Offline until they start duty. */
export const unblockMiler = async (id) => {
const response = await doormileAxios.put(`/admin/milers/${id}/unblock`, {});
return response.data;
};
export const assignMilerVehicle = async (id, data) => { export const assignMilerVehicle = async (id, data) => {
const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data); const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data);
return response.data; return response.data;

View File

@@ -417,7 +417,14 @@ export const useBlockMiler = () =>
useDoormileMutation({ useDoormileMutation({
mutationFn: ({ id, data }) => api.blockMiler(id, data), mutationFn: ({ id, data }) => api.blockMiler(id, data),
invalidates: [KEYS.milers], invalidates: [KEYS.milers],
successMessage: 'Rider updated', successMessage: 'Rider blocked',
});
export const useUnblockMiler = () =>
useDoormileMutation({
mutationFn: (id) => api.unblockMiler(id),
invalidates: [KEYS.milers],
successMessage: 'Rider unblocked',
}); });
export const useAssignMilerVehicle = () => export const useAssignMilerVehicle = () =>

92
src/lib/orderPricing.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* The delivery charge shown and sent (as `finalprice`) when ops create orders —
* the single order form and bulk upload. The backend stores the figure as
* given, so this IS the price the client is billed.
*
* What it replaces, in both pages:
* - A rate card value of 0 was overwritten by a made-up default
* (`Number(x) || 40`, `|| 10`, `|| 2`): a card set to "₹15 per km, no base
* fee, no free km" was charged ₹40 plus ₹15 for each km after the first 2.
* - A client without a rate card was priced off ANOTHER client's card
* (`list[0]`), or a hard-coded ₹40 + ₹10/km after 2 km.
* - The first card found for the client was used even if Inactive, and the
* Pricing page's "priority" was ignored.
*/
/**
* Doormile's standard rate, for a client with no rate card: ₹15 per km, at
* least 1 km billed. Without the minimum a trip under 1 km (or a distance the
* router rounded to 0) priced at ₹0, and the backend treats a 0 `finalprice` as
* "not given" and re-prices the booking from another client's card.
*/
export const STANDARD_RATE = Object.freeze({
baseprice: 0,
basedistance: 0,
priceperkm: 15,
minimumkm: 1,
isStandard: true,
});
/** A rate value as a number; an empty or invalid value is 0, never a default. */
const amount = (value) => {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : 0;
};
const isActive = (rule) => !rule?.status || String(rule.status).toLowerCase() === 'active';
/** Inside the card's effectivefrom / effectiveto window (either may be unset). */
const inEffect = (rule, now = Date.now()) => {
const from = rule?.effectivefrom ? Date.parse(rule.effectivefrom) : NaN;
const to = rule?.effectiveto ? Date.parse(rule.effectiveto) : NaN;
return (Number.isNaN(from) || from <= now) && (Number.isNaN(to) || to >= now);
};
/** The Pricing page's priority: lower wins; unset is 1, and 0 stays 0. */
const priorityOf = (rule) => {
const n = Number(rule?.priority);
return rule?.priority == null || rule.priority === '' || !Number.isFinite(n) ? 1 : n;
};
/**
* The rate card for a client: its Active rules, the lowest priority number
* first (the Pricing page's "lower wins"), newest on a tie. A Doormile-wide
* card (no client) applies next; failing both, the standard rate. Never
* another client's card.
*/
export function pickRateCard(pricingList, tenantId, now = Date.now()) {
const rules = (Array.isArray(pricingList) ? pricingList : []).filter((r) => isActive(r) && inEffect(r, now));
const byPriority = (a, b) => priorityOf(a) - priorityOf(b) || amount(b.pricingid) - amount(a.pricingid);
if (tenantId != null && String(tenantId) !== '' && String(tenantId) !== '0') {
const own = rules.filter((r) => String(r.tenantid) === String(tenantId)).sort(byPriority);
if (own.length) return own[0];
}
const doormileWide = rules.filter((r) => r.isdefault || !r.tenantid || String(r.tenantid) === '0').sort(byPriority);
return doormileWide[0] || STANDARD_RATE;
}
/**
* The charge for a trip of `distanceKm` on `rate`: the base price covers the
* first `basedistance` km, every km after that is `priceperkm`. Rounded to
* paise.
*/
export function chargeForDistance(distanceKm, rate = STANDARD_RATE) {
const km = Math.max(amount(distanceKm), amount(rate?.minimumkm));
const base = amount(rate?.baseprice);
const freeKm = amount(rate?.basedistance);
const perKm = amount(rate?.priceperkm);
const charge = km <= freeKm ? base : base + (km - freeKm) * perKm;
return Math.round(charge * 100) / 100;
}
/** "₹15 / km" style labels for the rate card panel. */
export function describeRate(rate) {
const base = amount(rate?.baseprice);
const freeKm = amount(rate?.basedistance);
return {
base: base > 0 ? `₹${base}${freeKm > 0 ? ` (first ${freeKm} km)` : ''}` : 'No base fee',
perKm: `₹${amount(rate?.priceperkm)} / km${amount(rate?.minimumkm) > 0 ? ` (min ${amount(rate.minimumkm)} km)` : ''}`,
source: rate?.isStandard ? 'Standard rate (no client rate card)' : 'Client rate card',
};
}

View File

@@ -46,10 +46,10 @@ import {
// point 300 m from the hub read "0 km". Both legs now display `meters`. // point 300 m from the hub read "0 km". Both legs now display `meters`.
import { import {
calculateDrivingRoute, calculateDrivingRoute,
calculateTotalCharge,
formatRouteDistance, formatRouteDistance,
formatRouteDuration formatRouteDuration
} from '@/lib/distance'; } from '@/lib/distance';
import { chargeForDistance, describeRate, pickRateCard } from '@/lib/orderPricing';
import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService'; import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService';
import { useHubs } from '@/lib/doormileHooks'; import { useHubs } from '@/lib/doormileHooks';
import { import {
@@ -514,17 +514,10 @@ export default function CreateOrder() {
[pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors] [pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors]
); );
// Tenant Pricing // The client's own active rate card, or the standard ₹15 / km — never another
const clientRate = useMemo(() => { // client's card (see lib/orderPricing).
const list = Array.isArray(pricingList) ? pricingList : []; const clientRate = useMemo(() => pickRateCard(pricingList, selectedTenantId), [pricingList, selectedTenantId]);
if (selectedTenantId) { const rateLabels = useMemo(() => describeRate(clientRate), [clientRate]);
const match = list.find((p) => String(p.tenantid) === String(selectedTenantId));
if (match) return match;
}
// Fallback to default pricing rule or standard system rates
const defaultRule = list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0);
return defaultRule || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 };
}, [pricingList, selectedTenantId]);
// Route calculation with full polyline for mini-map preview // Route calculation with full polyline for mini-map preview
useEffect(() => { useEffect(() => {
@@ -608,19 +601,12 @@ export default function CreateOrder() {
}; };
}, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]); }, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]);
// Total Charge calculation // Total charge: the base price covers the rate card's first km, then the
const totalCharge = useMemo(() => { // per-km rate. A 0 on the card stays 0 (it used to become ₹40 base + 2 free km).
const base = Number(clientRate?.baseprice) || 40; const totalCharge = useMemo(
const perKm = Number(clientRate?.priceperkm) || 10; () => chargeForDistance(route.resolved ? route.distance : 0, clientRate),
const minKm = Number(clientRate?.basedistance) || 2; [route, clientRate]
);
if (!route.resolved || !route.distance) {
return base;
}
const calculated = calculateTotalCharge(route.distance, base, perKm, minKm);
return Math.max(base, calculated);
}, [route, clientRate]);
// The selected hub location's city. Address suggestions come only from it: // The selected hub location's city. Address suggestions come only from it:
// a Coimbatore location searches Coimbatore, a Bengaluru or Hyderabad one // a Coimbatore location searches Coimbatore, a Bengaluru or Hyderabad one
@@ -801,6 +787,12 @@ export default function CreateOrder() {
OpenToast('Please choose the client this order is for, at the top of the form.', 'warning', 4000); OpenToast('Please choose the client this order is for, at the top of the form.', 'warning', 4000);
return; return;
} }
// The charge is calculated from the route; submitting before it lands sent
// ₹0, which the backend re-prices from another client's rate card.
if (!route.resolved) {
OpenToast('Still working out the route and price — try again in a moment.', 'warning', 4000);
return;
}
if (!pickCust.firstname.trim()) { if (!pickCust.firstname.trim()) {
OpenToast('Pickup contact name is missing. Enter the name of the person handing over the parcel.', 'warning', 4000); OpenToast('Pickup contact name is missing. Enter the name of the person handing over the parcel.', 'warning', 4000);
return; return;
@@ -1784,17 +1776,17 @@ export default function CreateOrder() {
{/* Rate Card Info */} {/* Rate Card Info */}
<div className="text-xs space-y-2 py-2 border-y border-slate-100"> <div className="text-xs space-y-2 py-2 border-y border-slate-100">
<div className="flex justify-between text-slate-600">
<span>Rate:</span>
<span className="font-semibold text-slate-800">{rateLabels.source}</span>
</div>
<div className="flex justify-between text-slate-600"> <div className="flex justify-between text-slate-600">
<span>Base Rate:</span> <span>Base Rate:</span>
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">{rateLabels.base}</span>
{clientRate ? `₹${clientRate.baseprice} (first ${clientRate.basedistance} km)` : '—'}
</span>
</div> </div>
<div className="flex justify-between text-slate-600"> <div className="flex justify-between text-slate-600">
<span>Per Km Rate:</span> <span>Per Km Rate:</span>
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">{rateLabels.perKm}</span>
{clientRate ? `₹${clientRate.priceperkm} / km` : '—'}
</span>
</div> </div>
<div className="flex justify-between text-slate-600"> <div className="flex justify-between text-slate-600">
<span>Total Items:</span> <span>Total Items:</span>

View File

@@ -38,7 +38,8 @@ import {
requiredSheetColumns requiredSheetColumns
} from '@/lib/bulkOrderColumns'; } from '@/lib/bulkOrderColumns';
import { coord, hasCoords } from '@/lib/coords'; import { coord, hasCoords } from '@/lib/coords';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance'; import { calculateDrivingDistance } from '@/lib/distance';
import { chargeForDistance, pickRateCard } from '@/lib/orderPricing';
import { useHubs } from '@/lib/doormileHooks'; import { useHubs } from '@/lib/doormileHooks';
import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow'; import { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow';
import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload'; import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload';
@@ -85,6 +86,7 @@ export default function MultipleOrders() {
const [otherInstructions, setOtherInstructions] = useState(''); const [otherInstructions, setOtherInstructions] = useState('');
const [basePrice, setBasePrice] = useState(0); const [basePrice, setBasePrice] = useState(0);
const [rateCard, setRateCard] = useState(null);
const [pricePerKm, setPricePerKm] = useState(0); const [pricePerKm, setPricePerKm] = useState(0);
const [minKm, setMinKm] = useState(0); const [minKm, setMinKm] = useState(0);
@@ -163,12 +165,13 @@ export default function MultipleOrders() {
// Pricing synchronization // Pricing synchronization
useEffect(() => { useEffect(() => {
const list = Array.isArray(pricingList) ? pricingList : []; // Same rate card and arithmetic as the single order form (lib/orderPricing):
const match = list.find((p) => String(p.tenantid) === String(selectedTenantId)); // the client's own active card or the standard ₹15 / km, and a 0 stays 0.
const rate = match || list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0) || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 }; const rate = pickRateCard(pricingList, selectedTenantId);
setBasePrice(Number(rate.baseprice) || 40); setRateCard(rate);
setPricePerKm(Number(rate.priceperkm) || 10); setBasePrice(Number(rate.baseprice) || 0);
setMinKm(Number(rate.basedistance) || 2); setPricePerKm(Number(rate.priceperkm) || 0);
setMinKm(Number(rate.basedistance) || 0);
}, [selectedTenantId, pricingList]); }, [selectedTenantId, pricingList]);
// Auto-select single location // Auto-select single location
@@ -212,7 +215,7 @@ export default function MultipleOrders() {
// collection near the recipient it bills a cross-town run that no rider // collection near the recipient it bills a cross-town run that no rider
// makes, and for one far from both it under-bills. // makes, and for one far from both it under-bills.
const roundedDistance = await calculateDrivingDistance(origin, customer); const roundedDistance = await calculateDrivingDistance(origin, customer);
const totalcharge = calculateTotalCharge(roundedDistance, basePrice, pricePerKm, minKm); const totalcharge = chargeForDistance(roundedDistance, rateCard || { baseprice: basePrice, priceperkm: pricePerKm, basedistance: minKm });
return { roundedDistance, totalcharge }; return { roundedDistance, totalcharge };
}; };
@@ -400,10 +403,10 @@ export default function MultipleOrders() {
const { roundedDistance, totalcharge } = await calculateDistance(enrichedCustomer); const { roundedDistance, totalcharge } = await calculateDistance(enrichedCustomer);
newDrops.push({ ...enrichedCustomer, distance: roundedDistance, totalcharge }); newDrops.push({ ...enrichedCustomer, distance: roundedDistance, totalcharge });
} else { } else {
newDrops.push({ ...enrichedCustomer, distance: 0, totalcharge: basePrice }); newDrops.push({ ...enrichedCustomer, distance: 0, totalcharge: chargeForDistance(0, rateCard || undefined) });
} }
} catch (err) { } catch (err) {
newDrops.push({ ...customer, distance: 0, totalcharge: basePrice }); newDrops.push({ ...customer, distance: 0, totalcharge: chargeForDistance(0, rateCard || undefined) });
} }
} }

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
@@ -25,7 +25,6 @@ const EMPTY_FORM = {
authname: '', authname: '',
email: '', email: '',
contactno: '', contactno: '',
password: '',
defaultvehicletype: 'Bike', defaultvehicletype: 'Bike',
tenantid: '', tenantid: '',
applocationid: '', applocationid: '',
@@ -51,6 +50,20 @@ export default function CreateRider() {
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value })); const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
// Only the hubs of the chosen city: a Coimbatore rider attached to a
// Hyderabad hub could be created before (the server now refuses it too).
const cityHubs = useMemo(
() => (form.applocationid ? (hubs || []).filter((hub) => String(hub.applocationid) === String(form.applocationid)) : []),
[hubs, form.applocationid]
);
const setCity = (value) =>
setForm((current) => {
const keepHub = (hubs || []).some(
(hub) => String(hub.hubid) === String(current.hubid) && String(hub.applocationid) === String(value)
);
return { ...current, applocationid: value, hubid: keepHub ? current.hubid : '' };
});
const submit = (event) => { const submit = (event) => {
event.preventDefault(); event.preventDefault();
@@ -59,8 +72,6 @@ export default function CreateRider() {
if (!form.authname.trim()) nextErrors.authname = 'Enter the login name for the rider app'; if (!form.authname.trim()) nextErrors.authname = 'Enter the login name for the rider app';
if (!/^\d{10}$/.test(form.contactno)) nextErrors.contactno = 'Enter a 10-digit mobile number'; if (!/^\d{10}$/.test(form.contactno)) nextErrors.contactno = 'Enter a 10-digit mobile number';
if (!form.email.trim()) nextErrors.email = 'Enter an email address'; if (!form.email.trim()) nextErrors.email = 'Enter an email address';
if (!form.password) nextErrors.password = 'Set a password';
if (isStaff && !form.tenantid) nextErrors.tenantid = 'Choose the client this rider works for';
if (!form.applocationid) nextErrors.applocationid = 'Choose the city'; if (!form.applocationid) nextErrors.applocationid = 'Choose the city';
setErrors(nextErrors); setErrors(nextErrors);
if (Object.keys(nextErrors).length) return; if (Object.keys(nextErrors).length) return;
@@ -71,8 +82,8 @@ export default function CreateRider() {
displayname: form.displayname, displayname: form.displayname,
email: form.email, email: form.email,
contactno: form.contactno, contactno: form.contactno,
password: form.password, // An empty client is Doormile's own pool (tenant 0).
tenantid: isStaff ? Number(form.tenantid) : Number(loginTenantId), tenantid: isStaff ? Number(form.tenantid || 0) : Number(loginTenantId),
defaultvehicletype: form.defaultvehicletype, defaultvehicletype: form.defaultvehicletype,
applocationid: Number(form.applocationid), applocationid: Number(form.applocationid),
hubid: form.hubid ? Number(form.hubid) : undefined, hubid: form.hubid ? Number(form.hubid) : undefined,
@@ -137,18 +148,14 @@ export default function CreateRider() {
/> />
</Field> </Field>
<Field label="Initial password" required error={errors.password}> {/* No password here: the server never stored one. The rider signs in
<Input with this mobile number and sets their own MPIN in the Miler app. */}
type="password" <Field label="Sign-in" hint="The rider sets their own MPIN in the Miler app on first sign-in">
autoComplete="new-password" <p className="py-2 text-body-sm text-ink-3">With the mobile number above</p>
value={form.password}
onChange={(e) => set('password')(e.target.value)}
placeholder="••••••••"
/>
</Field> </Field>
<Field label="Default vehicle"> <Field label="Default vehicle">
<Select value={form.defaultvehicletype} onValueChange={(value) => set('defaultvehicletype')(value)}> <Select value={form.defaultvehicletype} onValueChange={(value) => value && set('defaultvehicletype')(value)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -186,7 +193,7 @@ export default function CreateRider() {
<Field label="Zone / City" required error={errors.applocationid}> <Field label="Zone / City" required error={errors.applocationid}>
<Select <Select
value={form.applocationid === '' ? undefined : String(form.applocationid)} value={form.applocationid === '' ? undefined : String(form.applocationid)}
onValueChange={(value) => set('applocationid')(value)} onValueChange={(value) => value && setCity(value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choose a zone" /> <SelectValue placeholder="Choose a zone" />
@@ -203,17 +210,21 @@ export default function CreateRider() {
</Select> </Select>
</Field> </Field>
<Field label="Hub" hint="Optional — where the rider starts their shift"> <Field
label="Hub"
hint={form.applocationid ? 'Optional — where the rider starts their shift' : 'Choose the city first'}
>
<Select <Select
disabled={!form.applocationid}
value={form.hubid === '' ? NO_HUB : String(form.hubid)} value={form.hubid === '' ? NO_HUB : String(form.hubid)}
onValueChange={(value) => set('hubid')(value === NO_HUB ? '' : value)} onValueChange={(value) => value && set('hubid')(value === NO_HUB ? '' : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No hub" /> <SelectValue placeholder="No hub" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NO_HUB}>No hub</SelectItem> <SelectItem value={NO_HUB}>No hub</SelectItem>
{(hubs || []).map((hub) => ( {cityHubs.map((hub) => (
<SelectItem key={hub.hubid} value={String(hub.hubid)}> <SelectItem key={hub.hubid} value={String(hub.hubid)}>
{hub.hubname || `Hub #${hub.hubid}`} {hub.hubname || `Hub #${hub.hubid}`}
</SelectItem> </SelectItem>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { import {
@@ -6,7 +6,7 @@ import {
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select, BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,
SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, Surface, SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, Surface,
} from '@/components/ds'; } from '@/components/ds';
import { useAppLocations, useMiler, useTenants, useUpdateMiler } from '@/lib/doormileHooks'; import { useAppLocations, useHubs, useMiler, useTenants, useUpdateMiler } from '@/lib/doormileHooks';
/** /**
* Edit a rider. * Edit a rider.
@@ -16,14 +16,15 @@ import { useAppLocations, useMiler, useTenants, useUpdateMiler } from '@/lib/doo
* `/admin/milers/:id` route keys on that and 404s on the `userid` the same row * `/admin/milers/:id` route keys on that and 404s on the `userid` the same row
* also carries. * also carries.
* *
* Only identity, contact, client and zone are writable. `PUT /admin/milers/:id` * Writable: name, mobile number (the rider's sign-in), vehicle and hub — what
* documents no fields for bank details, vehicle settings or shift times, so * `PUT /admin/milers/:id` saves. Email, client and city are shown read-only:
* those are not offered here rather than shown as inputs that quietly discard * this form used to offer them as inputs and the server silently discarded
* what is typed into them. A miler has a single `displayname` — there is no * them, along with the phone. A miler has a single `displayname` — there is no
* first/last split anywhere on this resource. * first/last split anywhere on this resource.
*/ */
const NO_TENANT = 'none'; const NO_HUB = 'none';
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
export default function EditRider() { export default function EditRider() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,9 +36,13 @@ export default function EditRider() {
const { data: rider, isLoading } = useMiler(milerProfileId); const { data: rider, isLoading } = useMiler(milerProfileId);
const { data: tenants = [] } = useTenants(); const { data: tenants = [] } = useTenants();
const { data: locations = [] } = useAppLocations(); const { data: locations = [] } = useAppLocations();
const { data: hubs = [] } = useHubs();
const updateMiler = useUpdateMiler(); const updateMiler = useUpdateMiler();
const [form, setForm] = useState(null); const [form, setForm] = useState(null);
// What the form was loaded with, to send only fields that changed.
const [original, setOriginal] = useState({ phone: '', hubid: '' });
const [phoneError, setPhoneError] = useState('');
/* Seeded from the row that was clicked so the fields are populated on the /* Seeded from the row that was clicked so the fields are populated on the
first paint, then reconciled with the fetched record when it lands. */ first paint, then reconciled with the fetched record when it lands. */
@@ -50,11 +55,26 @@ export default function EditRider() {
email: source.email || '', email: source.email || '',
tenantid: source.tenantid ?? '', tenantid: source.tenantid ?? '',
applocationid: source.applocationid ?? '', applocationid: source.applocationid ?? '',
defaultvehicletype: source.defaultvehicletype || 'Bike',
hubid: source.hubid ?? '',
}); });
setOriginal({ phone: source.phone || source.contactno || '', hubid: source.hubid ?? '' });
}, [rider, passed]); }, [rider, passed]);
const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value })); const set = (key) => (value) => setForm((current) => ({ ...current, [key]: value }));
// Only hubs in the rider's own city; the server refuses any other.
const cityHubs = useMemo(
() => (hubs || []).filter((hub) => String(hub.applocationid) === String(form?.applocationid ?? '')),
[hubs, form?.applocationid]
);
const tenantName = (id) =>
!id || String(id) === '0'
? 'Doormile (no client)'
: (tenants || []).find((t) => String(t.tenantid) === String(id))?.tenantname || `Client #${id}`;
const cityName = (id) =>
(locations || []).find((l) => String(l.applocationid) === String(id))?.locationname || (id ? `City #${id}` : '—');
if (!milerProfileId) { if (!milerProfileId) {
return ( return (
<Stack space="lg"> <Stack space="lg">
@@ -71,15 +91,19 @@ export default function EditRider() {
const submit = (event) => { const submit = (event) => {
event.preventDefault(); event.preventDefault();
if (!/^[6-9]\d{9}$/.test(form.phone)) {
setPhoneError('Enter a 10-digit mobile number');
return;
}
setPhoneError('');
updateMiler.mutate( updateMiler.mutate(
{ {
id: milerProfileId, id: milerProfileId,
data: { data: {
displayname: form.displayname, displayname: form.displayname,
contactno: form.phone, defaultvehicletype: form.defaultvehicletype,
email: form.email, ...(form.phone !== original.phone ? { contactno: form.phone } : {}),
tenantid: form.tenantid === '' ? undefined : Number(form.tenantid), ...(form.hubid !== '' && String(form.hubid) !== String(original.hubid) ? { hubid: Number(form.hubid) } : {}),
applocationid: form.applocationid === '' ? undefined : Number(form.applocationid),
}, },
}, },
{ onSuccess: (res) => res?.success !== false && navigate('/doormile/milers') } { onSuccess: (res) => res?.success !== false && navigate('/doormile/milers') }
@@ -119,7 +143,7 @@ export default function EditRider() {
<Input value={form?.displayname || ''} onChange={(e) => set('displayname')(e.target.value)} /> <Input value={form?.displayname || ''} onChange={(e) => set('displayname')(e.target.value)} />
</Field> </Field>
<Field label="Mobile number"> <Field label="Mobile number" hint="The rider signs in with this number" error={phoneError}>
<Input <Input
value={form?.phone || ''} value={form?.phone || ''}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))} onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
@@ -127,48 +151,60 @@ export default function EditRider() {
/> />
</Field> </Field>
<Field label="Email"> <Field label="Default vehicle">
<Input type="email" value={form?.email || ''} onChange={(e) => set('email')(e.target.value)} /> {/* Radix can report '' (e.g. from its hidden native select) — never
</Field> let that blank the saved vehicle. */}
<Field label="Client">
<Select <Select
value={form?.tenantid === '' ? NO_TENANT : String(form?.tenantid ?? '')} value={form?.defaultvehicletype || 'Bike'}
onValueChange={(value) => set('tenantid')(value === NO_TENANT ? '' : value)} onValueChange={(value) => value && set('defaultvehicletype')(value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No client" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NO_TENANT}>No client</SelectItem> {VEHICLE_TYPES.map((type) => (
{(tenants || []).map((tenant) => ( <SelectItem key={type} value={type}>
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}> {type}
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field label="City"> <Field label="Hub" hint={`Hubs in ${cityName(form?.applocationid)}`}>
<Select <Select
value={String(form?.applocationid ?? '')} value={form?.hubid === '' || form?.hubid == null ? NO_HUB : String(form.hubid)}
onValueChange={set('applocationid')} onValueChange={(value) => value && set('hubid')(value === NO_HUB ? '' : value)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choose a city" /> <SelectValue placeholder="No hub" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(locations || []) <SelectItem value={NO_HUB} disabled={form?.hubid !== '' && form?.hubid != null}>
.filter((entry) => entry.applocationid) No hub
.map((entry) => ( </SelectItem>
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}> {cityHubs.map((hub) => (
{entry.locationname || `Location #${entry.applocationid}`} <SelectItem key={hub.hubid} value={String(hub.hubid)}>
{hub.hubname || `Hub #${hub.hubid}`}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
{/* Read-only: not changed from this form. The rider record does not
always carry the email, and a dash would wrongly say there is none. */}
{[
form?.email ? ['Email', form.email] : null,
['Client', tenantName(form?.tenantid)],
['City', cityName(form?.applocationid)],
]
.filter(Boolean)
.map(([label, value]) => (
<Field key={label} label={label} hint="Can't be changed here">
<p className="py-2 text-body-sm text-ink-2">{value}</p>
</Field>
))}
</div> </div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5"> <div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">

View File

@@ -5,7 +5,7 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { import {
Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, Pencil, Plus, Radio, Users, Ban, Bell, Bike, Car, CheckCircle2, CircleSlash, LockOpen, Pencil, Plus, Radio, Users,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
@@ -14,7 +14,7 @@ import {
} from '@/components/ds'; } from '@/components/ds';
import { import {
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler, useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
useRiderSummaryCounts, useVehicles, useRiderSummaryCounts, useUnblockMiler, useVehicles,
} from '@/lib/doormileHooks'; } from '@/lib/doormileHooks';
import { useZone } from '@/lib/ZoneContext'; import { useZone } from '@/lib/ZoneContext';
import { coordsOf } from '@/lib/coords'; import { coordsOf } from '@/lib/coords';
@@ -37,6 +37,7 @@ import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormil
const isActive = (rider) => !['offline', 'blocked'].includes(String(rider?.availabilitystatus || '').toLowerCase()); const isActive = (rider) => !['offline', 'blocked'].includes(String(rider?.availabilitystatus || '').toLowerCase());
const riderName = (rider) => rider?.displayname || rider?.authname || '—'; const riderName = (rider) => rider?.displayname || rider?.authname || '—';
const isBlocked = (rider) => String(rider?.availabilitystatus || '').toLowerCase() === 'blocked';
/** A bare `HH:mm:ss` from the shift record, rendered as a clock time. */ /** A bare `HH:mm:ss` from the shift record, rendered as a clock time. */
const shiftTime = (value) => const shiftTime = (value) =>
@@ -59,6 +60,7 @@ export default function Riders() {
const [tab, setTab] = useState('all'); const [tab, setTab] = useState('all');
const [blockRow, setBlockRow] = useState(null); const [blockRow, setBlockRow] = useState(null);
const [unblockRow, setUnblockRow] = useState(null);
const [vehicleRow, setVehicleRow] = useState(null); const [vehicleRow, setVehicleRow] = useState(null);
const [selectedVehicle, setSelectedVehicle] = useState(''); const [selectedVehicle, setSelectedVehicle] = useState('');
const [notifyRow, setNotifyRow] = useState(null); const [notifyRow, setNotifyRow] = useState(null);
@@ -66,6 +68,7 @@ export default function Riders() {
const [logsRow, setLogsRow] = useState(null); const [logsRow, setLogsRow] = useState(null);
const blockMiler = useBlockMiler(); const blockMiler = useBlockMiler();
const unblockMiler = useUnblockMiler();
const assignVehicle = useAssignMilerVehicle(); const assignVehicle = useAssignMilerVehicle();
const notifyMiler = useNotifyMiler(); const notifyMiler = useNotifyMiler();
@@ -250,7 +253,11 @@ export default function Riders() {
size="sm" size="sm"
onClick={() => setLogsRow(row)} onClick={() => setLogsRow(row)}
/> />
{isBlocked(row) ? (
<IconButton label="Unblock miler" icon={LockOpen} variant="ghost" size="sm" onClick={() => setUnblockRow(row)} />
) : (
<IconButton label="Block miler" icon={Ban} variant="ghost" size="sm" onClick={() => setBlockRow(row)} /> <IconButton label="Block miler" icon={Ban} variant="ghost" size="sm" onClick={() => setBlockRow(row)} />
)}
</div> </div>
), ),
}, },
@@ -382,6 +389,18 @@ export default function Riders() {
}} }}
/> />
<ConfirmModal
open={Boolean(unblockRow)}
onOpenChange={(open) => !open && setUnblockRow(null)}
title="Unblock this miler?"
description={`${riderName(unblockRow || {})} can sign in again and will receive assignments once they start duty.`}
confirmLabel="Unblock miler"
busy={unblockMiler.isPending}
onConfirm={() => {
unblockMiler.mutate(unblockRow.milerprofileid, { onSettled: () => setUnblockRow(null) });
}}
/>
<Modal <Modal
open={Boolean(vehicleRow)} open={Boolean(vehicleRow)}
onOpenChange={(open) => !open && setVehicleRow(null)} onOpenChange={(open) => !open && setVehicleRow(null)}

View File

@@ -0,0 +1,156 @@
import React from 'react';
import { configure, render, screen, fireEvent, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
configure({ asyncUtilTimeout: 5000 });
/**
* Rider (miler) create and edit forms. The hooks are mocked; the server rules
* (duplicate phone, hub in another city, block holding) are covered by
* doormile_backend's routes_miler_account_pg_test.go.
*/
beforeAll(() => {
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
// Radix Select needs these in jsdom.
window.HTMLElement.prototype.hasPointerCapture = window.HTMLElement.prototype.hasPointerCapture || (() => false);
window.HTMLElement.prototype.releasePointerCapture = window.HTMLElement.prototype.releasePointerCapture || (() => {});
window.HTMLElement.prototype.scrollIntoView = window.HTMLElement.prototype.scrollIntoView || (() => {});
});
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
const HUBS = [
{ hubid: 11, hubname: 'Coimbatore Neptune Hub', applocationid: 1 },
{ hubid: 21, hubname: 'Hyderabad Mars Hub', applocationid: 2 },
];
const CITIES = [
{ applocationid: 1, locationname: 'Coimbatore' },
{ applocationid: 2, locationname: 'Hyderabad' },
];
let mockRider = null;
jest.mock('@/lib/doormileHooks', () => ({
useTenants: () => ({ data: [{ tenantid: 5, tenantname: "Sai's Kitchen" }] }),
useHubs: () => ({ data: HUBS }),
useAppLocations: () => ({ data: CITIES }),
useCreateMiler: () => ({ mutate: mockCreate, isPending: false }),
useUpdateMiler: () => ({ mutate: mockUpdate, isPending: false }),
useMiler: () => ({ data: mockRider, isLoading: false }),
}));
import CreateRider from '@/pages/doormile/riders/CreateRider';
import EditRider from '@/pages/doormile/riders/EditRider';
/** Opens a Radix select by its trigger and returns the visible option labels. */
const openSelect = (trigger) => {
fireEvent.keyDown(trigger, { key: 'Enter' });
return screen.getAllByRole('option').map((o) => o.textContent);
};
const pick = (trigger, label) => {
openSelect(trigger);
fireEvent.click(screen.getByRole('option', { name: label }));
};
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockRider = null;
});
describe('Create miler', () => {
const fill = () => {
fireEvent.change(screen.getByPlaceholderText('9876543210'), { target: { value: '9876500001' } });
fireEvent.change(screen.getByPlaceholderText('murali@example.com'), { target: { value: 'ravi@r.test' } });
const inputs = screen.getAllByRole('textbox');
// Display name and login name are the first two text inputs.
fireEvent.change(inputs[0], { target: { value: 'Ravi K' } });
fireEvent.change(inputs[1], { target: { value: 'ravi' } });
};
it('asks for no password: the rider sets their own MPIN in the app', () => {
render(<MemoryRouter><CreateRider /></MemoryRouter>);
expect(screen.queryByText('Initial password')).not.toBeInTheDocument();
expect(screen.getByText(/sets their own MPIN/)).toBeInTheDocument();
});
it('offers only the hubs of the chosen city', () => {
render(<MemoryRouter><CreateRider /></MemoryRouter>);
// Staff form: vehicle, client, city, hub.
const [, , city, hub] = screen.getAllByRole('combobox');
pick(city, 'Coimbatore');
const options = openSelect(hub);
expect(options).toContain('Coimbatore Neptune Hub');
expect(options).not.toContain('Hyderabad Mars Hub');
});
it("creates a rider in Doormile's own pool when no client is chosen", () => {
render(<MemoryRouter><CreateRider /></MemoryRouter>);
fill();
pick(screen.getAllByRole('combobox')[2], 'Coimbatore');
fireEvent.click(screen.getByRole('button', { name: /create miler/i }));
expect(mockCreate).toHaveBeenCalledTimes(1);
const payload = mockCreate.mock.calls[0][0];
expect(payload).toMatchObject({ tenantid: 0, applocationid: 1, contactno: '9876500001' });
expect(payload).not.toHaveProperty('password');
});
});
describe('Edit miler', () => {
const RIDER = {
milerprofileid: 7, displayname: 'Nagalakshmi', phone: '9876500004', email: 'naga@r.test',
tenantid: 5, applocationid: 1, hubid: 11, defaultvehicletype: 'Scooter',
};
const renderEdit = () =>
render(
<MemoryRouter initialEntries={[{ pathname: '/doormile/milers/edit', state: { rider: RIDER } }]}>
<EditRider />
</MemoryRouter>
);
it('saves the changed phone (and not the unchanged hub) — and shows email, client and city read-only', () => {
mockRider = RIDER;
renderEdit();
expect(screen.getByText('naga@r.test')).toBeInTheDocument();
expect(screen.getByText("Sai's Kitchen")).toBeInTheDocument();
expect(screen.getAllByText(/Can't be changed here/)).toHaveLength(3);
fireEvent.change(screen.getByDisplayValue('9876500004'), { target: { value: '9876500007' } });
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(mockUpdate).toHaveBeenCalledTimes(1);
const { id, data } = mockUpdate.mock.calls[0][0];
expect(id).toBe(7);
// Only what changed: re-sending the old hub could fail a legacy rider's save.
expect(data).toEqual({ displayname: 'Nagalakshmi', contactno: '9876500007', defaultvehicletype: 'Scooter' });
});
it('a rename alone does not resend the login phone', () => {
mockRider = RIDER;
renderEdit();
fireEvent.change(screen.getByDisplayValue('Nagalakshmi'), { target: { value: 'Nagalakshmi R' } });
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(mockUpdate.mock.calls[0][0].data).toEqual({ displayname: 'Nagalakshmi R', defaultvehicletype: 'Scooter' });
});
it("offers only hubs in the rider's city", () => {
mockRider = RIDER;
renderEdit();
const hubTrigger = screen.getAllByRole('combobox')[1];
const options = openSelect(hubTrigger);
expect(options).toContain('Coimbatore Neptune Hub');
expect(options).not.toContain('Hyderabad Mars Hub');
});
it('refuses a mobile number that is not 10 digits', () => {
mockRider = RIDER;
renderEdit();
fireEvent.change(screen.getByDisplayValue('9876500004'), { target: { value: '98765' } });
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(mockUpdate).not.toHaveBeenCalled();
expect(within(document.body).getByText('Enter a 10-digit mobile number')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { STANDARD_RATE, chargeForDistance, describeRate, pickRateCard } from '@/lib/orderPricing';
describe('order pricing', () => {
it('charges ₹15 per km on the standard rate', () => {
expect(chargeForDistance(1, STANDARD_RATE)).toBe(15);
expect(chargeForDistance(10, STANDARD_RATE)).toBe(150);
expect(chargeForDistance(3.4, STANDARD_RATE)).toBe(51);
});
it('bills at least 1 km on the standard rate, so an order is never ₹0', () => {
// A 0 finalprice makes the backend re-price from another client's card.
expect(chargeForDistance(0, STANDARD_RATE)).toBe(15);
expect(chargeForDistance(0.3, STANDARD_RATE)).toBe(15);
expect(chargeForDistance(1.2, STANDARD_RATE)).toBe(18);
});
it('ignores a card outside its effective dates, and keeps priority 0 as the top', () => {
const now = Date.parse('2026-10-07T12:00:00Z');
const list = [
{ pricingid: 1, tenantid: 5, priceperkm: 9, priority: 0, status: 'Active', effectiveto: '2026-09-30T00:00:00Z' },
{ pricingid: 2, tenantid: 5, priceperkm: 20, priority: 1, status: 'Active' },
{ pricingid: 3, tenantid: 5, priceperkm: 18, priority: 0, status: 'Active', effectivefrom: '2026-10-01T00:00:00Z' },
];
expect(pickRateCard(list, 5, now).pricingid).toBe(3);
});
it('keeps a rate card value of 0 as 0 — no made-up ₹40 base or 2 free km', () => {
const card = { tenantid: 5, baseprice: 0, basedistance: 0, priceperkm: 15 };
expect(chargeForDistance(4, card)).toBe(60); // was 40 + (4 - 2) × 15 = 70
});
it('applies a base fee that covers the first km, then the per-km rate', () => {
const card = { baseprice: 40, basedistance: 2, priceperkm: 15 };
expect(chargeForDistance(1.5, card)).toBe(40);
expect(chargeForDistance(5, card)).toBe(85);
});
it("uses the client's own active card, lowest priority first", () => {
const list = [
{ pricingid: 1, tenantid: 9, priceperkm: 99, status: 'Active' },
{ pricingid: 2, tenantid: 5, priceperkm: 20, priority: 2, status: 'Active' },
{ pricingid: 3, tenantid: 5, priceperkm: 18, priority: 1, status: 'Active' },
{ pricingid: 4, tenantid: 5, priceperkm: 5, priority: 0, status: 'Inactive' },
];
expect(pickRateCard(list, 5).pricingid).toBe(3);
});
it("never prices a client off another client's card", () => {
const list = [{ pricingid: 1, tenantid: 9, priceperkm: 99, status: 'Active' }];
expect(pickRateCard(list, 5)).toBe(STANDARD_RATE);
expect(pickRateCard([], 5)).toBe(STANDARD_RATE);
expect(pickRateCard(undefined, '')).toBe(STANDARD_RATE);
});
it('falls back to a Doormile-wide card before the standard rate', () => {
const list = [{ pricingid: 7, tenantid: 0, priceperkm: 12, status: 'Active' }];
expect(pickRateCard(list, 5).pricingid).toBe(7);
});
it('labels the rate it used', () => {
expect(describeRate(STANDARD_RATE)).toEqual({
base: 'No base fee',
perKm: '₹15 / km (min 1 km)',
source: 'Standard rate (no client rate card)',
});
expect(describeRate({ baseprice: 40, basedistance: 2, priceperkm: 15 }).base).toBe('₹40 (first 2 km)');
});
});