diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index 325752b..f969284 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -375,6 +375,12 @@ export const blockMiler = async (id, 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) => { const response = await doormileAxios.put(`/admin/milers/${id}/assign-vehicle`, data); return response.data; diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index 1c85349..bbfc585 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -417,7 +417,14 @@ export const useBlockMiler = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.blockMiler(id, data), 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 = () => diff --git a/src/lib/orderPricing.js b/src/lib/orderPricing.js new file mode 100644 index 0000000..82a83fb --- /dev/null +++ b/src/lib/orderPricing.js @@ -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', + }; +} diff --git a/src/pages/doormile/orders/CreateOrder.jsx b/src/pages/doormile/orders/CreateOrder.jsx index 3554ac1..88a3b08 100644 --- a/src/pages/doormile/orders/CreateOrder.jsx +++ b/src/pages/doormile/orders/CreateOrder.jsx @@ -46,10 +46,10 @@ import { // point 300 m from the hub read "0 km". Both legs now display `meters`. import { calculateDrivingRoute, - calculateTotalCharge, formatRouteDistance, formatRouteDuration } from '@/lib/distance'; +import { chargeForDistance, describeRate, pickRateCard } from '@/lib/orderPricing'; import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService'; import { useHubs } from '@/lib/doormileHooks'; import { @@ -514,17 +514,10 @@ export default function CreateOrder() { [pickupMode, pickCust.latitude, pickCust.longitude, dropCust.latitude, dropCust.longitude, flowAnchors] ); - // Tenant Pricing - const clientRate = useMemo(() => { - const list = Array.isArray(pricingList) ? pricingList : []; - if (selectedTenantId) { - 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]); + // The client's own active rate card, or the standard ₹15 / km — never another + // client's card (see lib/orderPricing). + const clientRate = useMemo(() => pickRateCard(pricingList, selectedTenantId), [pricingList, selectedTenantId]); + const rateLabels = useMemo(() => describeRate(clientRate), [clientRate]); // Route calculation with full polyline for mini-map preview useEffect(() => { @@ -608,19 +601,12 @@ export default function CreateOrder() { }; }, [isCustomerPickup, selectedLocation?.latitude, selectedLocation?.longitude, pickCust.latitude, pickCust.longitude]); - // Total Charge calculation - const totalCharge = useMemo(() => { - const base = Number(clientRate?.baseprice) || 40; - const perKm = Number(clientRate?.priceperkm) || 10; - const minKm = Number(clientRate?.basedistance) || 2; - - if (!route.resolved || !route.distance) { - return base; - } - - const calculated = calculateTotalCharge(route.distance, base, perKm, minKm); - return Math.max(base, calculated); - }, [route, clientRate]); + // Total charge: the base price covers the rate card's first km, then the + // per-km rate. A 0 on the card stays 0 (it used to become ₹40 base + 2 free km). + const totalCharge = useMemo( + () => chargeForDistance(route.resolved ? route.distance : 0, clientRate), + [route, clientRate] + ); // The selected hub location's city. Address suggestions come only from it: // 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); 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()) { OpenToast('Pickup contact name is missing. Enter the name of the person handing over the parcel.', 'warning', 4000); return; @@ -1784,17 +1776,17 @@ export default function CreateOrder() { {/* Rate Card Info */}
+
+ Rate: + {rateLabels.source} +
Base Rate: - - {clientRate ? `₹${clientRate.baseprice} (first ${clientRate.basedistance} km)` : '—'} - + {rateLabels.base}
Per Km Rate: - - {clientRate ? `₹${clientRate.priceperkm} / km` : '—'} - + {rateLabels.perKm}
Total Items: diff --git a/src/pages/doormile/orders/MultipleOrders.jsx b/src/pages/doormile/orders/MultipleOrders.jsx index 81e7a6c..be789fc 100644 --- a/src/pages/doormile/orders/MultipleOrders.jsx +++ b/src/pages/doormile/orders/MultipleOrders.jsx @@ -38,7 +38,8 @@ import { requiredSheetColumns } from '@/lib/bulkOrderColumns'; 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 { PICKUP_SOURCE, buildAnchors } from '@/lib/orderFlow'; import { buildBulkBookingPayloads, rowPickupOf } from '@/lib/bulkOrderPayload'; @@ -85,6 +86,7 @@ export default function MultipleOrders() { const [otherInstructions, setOtherInstructions] = useState(''); const [basePrice, setBasePrice] = useState(0); + const [rateCard, setRateCard] = useState(null); const [pricePerKm, setPricePerKm] = useState(0); const [minKm, setMinKm] = useState(0); @@ -163,12 +165,13 @@ export default function MultipleOrders() { // Pricing synchronization useEffect(() => { - const list = Array.isArray(pricingList) ? pricingList : []; - const match = list.find((p) => String(p.tenantid) === String(selectedTenantId)); - const rate = match || list.find((p) => p.isdefault || !p.tenantid || p.tenantid === 0) || list[0] || { baseprice: 40, priceperkm: 10, basedistance: 2 }; - setBasePrice(Number(rate.baseprice) || 40); - setPricePerKm(Number(rate.priceperkm) || 10); - setMinKm(Number(rate.basedistance) || 2); + // Same rate card and arithmetic as the single order form (lib/orderPricing): + // the client's own active card or the standard ₹15 / km, and a 0 stays 0. + const rate = pickRateCard(pricingList, selectedTenantId); + setRateCard(rate); + setBasePrice(Number(rate.baseprice) || 0); + setPricePerKm(Number(rate.priceperkm) || 0); + setMinKm(Number(rate.basedistance) || 0); }, [selectedTenantId, pricingList]); // 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 // makes, and for one far from both it under-bills. 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 }; }; @@ -400,10 +403,10 @@ export default function MultipleOrders() { const { roundedDistance, totalcharge } = await calculateDistance(enrichedCustomer); newDrops.push({ ...enrichedCustomer, distance: roundedDistance, totalcharge }); } else { - newDrops.push({ ...enrichedCustomer, distance: 0, totalcharge: basePrice }); + newDrops.push({ ...enrichedCustomer, distance: 0, totalcharge: chargeForDistance(0, rateCard || undefined) }); } } catch (err) { - newDrops.push({ ...customer, distance: 0, totalcharge: basePrice }); + newDrops.push({ ...customer, distance: 0, totalcharge: chargeForDistance(0, rateCard || undefined) }); } } diff --git a/src/pages/doormile/riders/CreateRider.jsx b/src/pages/doormile/riders/CreateRider.jsx index 94d67c0..d39c27a 100644 --- a/src/pages/doormile/riders/CreateRider.jsx +++ b/src/pages/doormile/riders/CreateRider.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeft } from 'lucide-react'; import { @@ -25,7 +25,6 @@ const EMPTY_FORM = { authname: '', email: '', contactno: '', - password: '', defaultvehicletype: 'Bike', tenantid: '', applocationid: '', @@ -51,6 +50,20 @@ export default function CreateRider() { 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) => { 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 (!/^\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.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'; setErrors(nextErrors); if (Object.keys(nextErrors).length) return; @@ -71,8 +82,8 @@ export default function CreateRider() { displayname: form.displayname, email: form.email, contactno: form.contactno, - password: form.password, - tenantid: isStaff ? Number(form.tenantid) : Number(loginTenantId), + // An empty client is Doormile's own pool (tenant 0). + tenantid: isStaff ? Number(form.tenantid || 0) : Number(loginTenantId), defaultvehicletype: form.defaultvehicletype, applocationid: Number(form.applocationid), hubid: form.hubid ? Number(form.hubid) : undefined, @@ -137,18 +148,14 @@ export default function CreateRider() { /> - - set('password')(e.target.value)} - placeholder="••••••••" - /> + {/* No password here: the server never stored one. The rider signs in + with this mobile number and sets their own MPIN in the Miler app. */} + +

With the mobile number above

- value && set('defaultvehicletype')(value)}> @@ -186,7 +193,7 @@ export default function CreateRider() { - + set('displayname')(e.target.value)} /> - + set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))} @@ -127,48 +151,60 @@ export default function EditRider() { /> - - set('email')(e.target.value)} /> - - - + + {/* Radix can report '' (e.g. from its hidden native select) — never + let that blank the saved vehicle. */} - + + + {/* 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]) => ( + +

{value}

+
+ ))}
diff --git a/src/pages/doormile/riders/Riders.jsx b/src/pages/doormile/riders/Riders.jsx index 77efd1f..700be46 100644 --- a/src/pages/doormile/riders/Riders.jsx +++ b/src/pages/doormile/riders/Riders.jsx @@ -5,7 +5,7 @@ import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; 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'; import { Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton, @@ -14,7 +14,7 @@ import { } from '@/components/ds'; import { useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler, - useRiderSummaryCounts, useVehicles, + useRiderSummaryCounts, useUnblockMiler, useVehicles, } from '@/lib/doormileHooks'; import { useZone } from '@/lib/ZoneContext'; 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 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. */ const shiftTime = (value) => @@ -59,6 +60,7 @@ export default function Riders() { const [tab, setTab] = useState('all'); const [blockRow, setBlockRow] = useState(null); + const [unblockRow, setUnblockRow] = useState(null); const [vehicleRow, setVehicleRow] = useState(null); const [selectedVehicle, setSelectedVehicle] = useState(''); const [notifyRow, setNotifyRow] = useState(null); @@ -66,6 +68,7 @@ export default function Riders() { const [logsRow, setLogsRow] = useState(null); const blockMiler = useBlockMiler(); + const unblockMiler = useUnblockMiler(); const assignVehicle = useAssignMilerVehicle(); const notifyMiler = useNotifyMiler(); @@ -250,7 +253,11 @@ export default function Riders() { size="sm" onClick={() => setLogsRow(row)} /> - setBlockRow(row)} /> + {isBlocked(row) ? ( + setUnblockRow(row)} /> + ) : ( + setBlockRow(row)} /> + )}
), }, @@ -382,6 +389,18 @@ export default function Riders() { }} /> + !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) }); + }} + /> + !open && setVehicleRow(null)} diff --git a/tests/integration/milerAccount.test.jsx b/tests/integration/milerAccount.test.jsx new file mode 100644 index 0000000..8501c89 --- /dev/null +++ b/tests/integration/milerAccount.test.jsx @@ -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) => ) }) +); + +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(); + 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(); + // 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(); + 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( + + + + ); + + 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(); + }); +}); diff --git a/tests/lib/orderPricing.test.js b/tests/lib/orderPricing.test.js new file mode 100644 index 0000000..7d9219c --- /dev/null +++ b/tests/lib/orderPricing.test.js @@ -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)'); + }); +});