updates on the miler page and the create order calculations as well
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
92
src/lib/orderPricing.js
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
156
tests/integration/milerAccount.test.jsx
Normal file
156
tests/integration/milerAccount.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/lib/orderPricing.test.js
Normal file
68
tests/lib/orderPricing.test.js
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user