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;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
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`.
|
||||
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 */}
|
||||
<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">
|
||||
<span>Base Rate:</span>
|
||||
<span className="font-semibold text-slate-800">
|
||||
{clientRate ? `₹${clientRate.baseprice} (first ${clientRate.basedistance} km)` : '—'}
|
||||
</span>
|
||||
<span className="font-semibold text-slate-800">{rateLabels.base}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Per Km Rate:</span>
|
||||
<span className="font-semibold text-slate-800">
|
||||
{clientRate ? `₹${clientRate.priceperkm} / km` : '—'}
|
||||
</span>
|
||||
<span className="font-semibold text-slate-800">{rateLabels.perKm}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-600">
|
||||
<span>Total Items:</span>
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Initial password" required error={errors.password}>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(e) => 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. */}
|
||||
<Field label="Sign-in" hint="The rider sets their own MPIN in the Miler app on first sign-in">
|
||||
<p className="py-2 text-body-sm text-ink-3">With the mobile number above</p>
|
||||
</Field>
|
||||
|
||||
<Field label="Default vehicle">
|
||||
<Select value={form.defaultvehicletype} onValueChange={(value) => set('defaultvehicletype')(value)}>
|
||||
<Select value={form.defaultvehicletype} onValueChange={(value) => value && set('defaultvehicletype')(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -186,7 +193,7 @@ export default function CreateRider() {
|
||||
<Field label="Zone / City" required error={errors.applocationid}>
|
||||
<Select
|
||||
value={form.applocationid === '' ? undefined : String(form.applocationid)}
|
||||
onValueChange={(value) => set('applocationid')(value)}
|
||||
onValueChange={(value) => value && setCity(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a zone" />
|
||||
@@ -203,17 +210,21 @@ export default function CreateRider() {
|
||||
</Select>
|
||||
</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
|
||||
disabled={!form.applocationid}
|
||||
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>
|
||||
<SelectValue placeholder="No hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_HUB}>No hub</SelectItem>
|
||||
{(hubs || []).map((hub) => (
|
||||
{cityHubs.map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</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 { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
BreadcrumbSeparator, Button, Field, Input, LoadingState, PageHeader, Select,
|
||||
SelectContent, SelectItem, SelectTrigger, SelectValue, Stack, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useAppLocations, useMiler, useTenants, useUpdateMiler } from '@/lib/doormileHooks';
|
||||
import { useAppLocations, useHubs, useMiler, useTenants, useUpdateMiler } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* also carries.
|
||||
*
|
||||
* Only identity, contact, client and zone are writable. `PUT /admin/milers/:id`
|
||||
* documents no fields for bank details, vehicle settings or shift times, so
|
||||
* those are not offered here rather than shown as inputs that quietly discard
|
||||
* what is typed into them. A miler has a single `displayname` — there is no
|
||||
* Writable: name, mobile number (the rider's sign-in), vehicle and hub — what
|
||||
* `PUT /admin/milers/:id` saves. Email, client and city are shown read-only:
|
||||
* this form used to offer them as inputs and the server silently discarded
|
||||
* them, along with the phone. A miler has a single `displayname` — there is no
|
||||
* 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() {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,9 +36,13 @@ export default function EditRider() {
|
||||
const { data: rider, isLoading } = useMiler(milerProfileId);
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const { data: hubs = [] } = useHubs();
|
||||
const updateMiler = useUpdateMiler();
|
||||
|
||||
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
|
||||
first paint, then reconciled with the fetched record when it lands. */
|
||||
@@ -50,11 +55,26 @@ export default function EditRider() {
|
||||
email: source.email || '',
|
||||
tenantid: source.tenantid ?? '',
|
||||
applocationid: source.applocationid ?? '',
|
||||
defaultvehicletype: source.defaultvehicletype || 'Bike',
|
||||
hubid: source.hubid ?? '',
|
||||
});
|
||||
setOriginal({ phone: source.phone || source.contactno || '', hubid: source.hubid ?? '' });
|
||||
}, [rider, passed]);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<Stack space="lg">
|
||||
@@ -71,15 +91,19 @@ export default function EditRider() {
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
if (!/^[6-9]\d{9}$/.test(form.phone)) {
|
||||
setPhoneError('Enter a 10-digit mobile number');
|
||||
return;
|
||||
}
|
||||
setPhoneError('');
|
||||
updateMiler.mutate(
|
||||
{
|
||||
id: milerProfileId,
|
||||
data: {
|
||||
displayname: form.displayname,
|
||||
contactno: form.phone,
|
||||
email: form.email,
|
||||
tenantid: form.tenantid === '' ? undefined : Number(form.tenantid),
|
||||
applocationid: form.applocationid === '' ? undefined : Number(form.applocationid),
|
||||
defaultvehicletype: form.defaultvehicletype,
|
||||
...(form.phone !== original.phone ? { contactno: form.phone } : {}),
|
||||
...(form.hubid !== '' && String(form.hubid) !== String(original.hubid) ? { hubid: Number(form.hubid) } : {}),
|
||||
},
|
||||
},
|
||||
{ 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)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Mobile number">
|
||||
<Field label="Mobile number" hint="The rider signs in with this number" error={phoneError}>
|
||||
<Input
|
||||
value={form?.phone || ''}
|
||||
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
@@ -127,48 +151,60 @@ export default function EditRider() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email">
|
||||
<Input type="email" value={form?.email || ''} onChange={(e) => set('email')(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Client">
|
||||
<Field label="Default vehicle">
|
||||
{/* Radix can report '' (e.g. from its hidden native select) — never
|
||||
let that blank the saved vehicle. */}
|
||||
<Select
|
||||
value={form?.tenantid === '' ? NO_TENANT : String(form?.tenantid ?? '')}
|
||||
onValueChange={(value) => set('tenantid')(value === NO_TENANT ? '' : value)}
|
||||
value={form?.defaultvehicletype || 'Bike'}
|
||||
onValueChange={(value) => value && set('defaultvehicletype')(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_TENANT}>No client</SelectItem>
|
||||
{(tenants || []).map((tenant) => (
|
||||
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
|
||||
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="City">
|
||||
<Field label="Hub" hint={`Hubs in ${cityName(form?.applocationid)}`}>
|
||||
<Select
|
||||
value={String(form?.applocationid ?? '')}
|
||||
onValueChange={set('applocationid')}
|
||||
value={form?.hubid === '' || form?.hubid == null ? NO_HUB : String(form.hubid)}
|
||||
onValueChange={(value) => value && set('hubid')(value === NO_HUB ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a city" />
|
||||
<SelectValue placeholder="No hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname || `Location #${entry.applocationid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={NO_HUB} disabled={form?.hubid !== '' && form?.hubid != null}>
|
||||
No hub
|
||||
</SelectItem>
|
||||
{cityHubs.map((hub) => (
|
||||
<SelectItem key={hub.hubid} value={String(hub.hubid)}>
|
||||
{hub.hubname || `Hub #${hub.hubid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 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 '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)}
|
||||
/>
|
||||
<IconButton label="Block miler" icon={Ban} variant="ghost" size="sm" onClick={() => setBlockRow(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)} />
|
||||
)}
|
||||
</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
|
||||
open={Boolean(vehicleRow)}
|
||||
onOpenChange={(open) => !open && setVehicleRow(null)}
|
||||
|
||||
Reference in New Issue
Block a user