updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View File

@@ -0,0 +1,268 @@
import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
import { getAdminTenants } from '@/api/doormile/endpoints';
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational create-order ||============================== //
//
// One question at a time, mirroring createorder1.js's own field set and using a
// DROPDOWN wherever that page uses one — business location, customer, category,
// weight, service tier. Free text is only for things that genuinely are free
// text (a name, an address, a description).
//
// Two capabilities the customer flow didn't need:
//
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
// straight to picking from the real customer list, new asks for the fields.
// Steps carry a `when` predicate and are skipped when it's false.
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
// dropdown never shows a stale or invented list.
//
// The conversation is driven by the PANEL, which intercepts replies before the
// router ever sees them — see customerFlow.js for why that matters (a bare
// "8494948494" matches no intent and used to be discarded).
const PHONE_RE = /^\d{10}$/;
const digits = (t) => String(t || '').replace(/\D/g, '');
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
// ---- steps ------------------------------------------------------------------
//
// type: 'select' → the panel renders a dropdown from `options(draft)`
// 'text' → answered through the composer
// when: omitted means always asked
export const ORDER_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant is this order for?',
// A client login already has its tenant; only Doormile staff choose.
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
ask: 'Which business location is this picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
// The pincode is shown because it decides CityGate — a location outside
// the open cities will be refused server-side, and the operator should
// see that before choosing rather than after submitting.
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'customerMode',
type: 'select',
ask: 'Is this an existing customer, or a new one?',
options: async () => [
{ value: 'existing', label: 'Existing customer' },
{ value: 'new', label: 'New customer' }
],
apply: (d, v) => ({ ...d, customerMode: v })
},
{
id: 'existingCustomer',
type: 'select',
ask: 'Which customer?',
when: (d) => d.customerMode === 'existing',
options: async () => {
const customers = (await getAdminCustomers()) || [];
return customers
.filter((c) => c.phone)
.map((c) => ({
value: String(c.appcustomerid ?? c.id),
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
record: c
}));
},
// Picking an existing customer fills the name and phone, so the two
// free-text steps below are skipped by their own `when`.
apply: (d, v, option) => ({
...d,
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
customer_phone: digits(option?.record?.phone)
})
},
{
id: 'customer_name',
type: 'text',
ask: 'What’s the customer’s name?',
when: (d) => d.customerMode === 'new' && !d.customer_name,
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
},
{
id: 'customer_phone',
type: 'text',
ask: 'And their 10-digit mobile number?',
when: (d) => !d.customer_phone,
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
},
{
id: 'deliveryaddress',
type: 'text',
ask: 'Where is it being delivered? Give the full address.',
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
// an address that can't be located is refused here rather than becoming a
// booking nothing can dispatch.
resolve: async (t) => {
const place = await geocodeAddress(String(t).trim()).catch(() => null);
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
(place.address_components || []).forEach((c) => {
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
});
return {
value: {
...parts,
deliverylatitude: place.geometry?.location?.lat?.(),
deliverylongitude: place.geometry?.location?.lng?.()
}
};
},
apply: (d, v) => ({ ...d, ...v })
},
{
id: 'deliverypincode',
type: 'text',
ask: 'What’s the delivery pincode?',
when: (d) => !d.deliverypincode,
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'itemcategory',
type: 'select',
ask: 'What kind of parcel is it?',
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
apply: (d, v) => ({ ...d, itemcategory: v })
},
{
id: 'weight',
type: 'select',
ask: 'Roughly how heavy?',
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
apply: (d, v) => ({ ...d, weight: v })
},
{
id: 'itemdescription',
type: 'text',
ask: 'Briefly, what’s inside?',
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
},
{
id: 'quantity',
type: 'select',
ask: 'How many parcels?',
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
},
{
id: 'finalprice',
type: 'text',
ask: 'What should the price be? Enter the amount in ₹.',
// `auto` answers a step from real data and only falls back to asking. The
// quote is stashed either way so the confirmation can show the distance it
// measured, and say why it couldn’t price when it couldn’t.
auto: async (d) => {
const quote = await priceOrder(d);
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
return {
patch: { __quote: quote },
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
};
},
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
apply: (d, t) => ({ ...d, finalprice: Number(t) })
}
];
// Mirrors createorder1.js's own lists so the bot offers the same choices.
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
// ---- pricing ----------------------------------------------------------------
//
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
// this tenant's own pricing row. Quoted, never invented — if no pricing row
// matches, the operator is asked for the amount rather than shown a zero.
export const priceOrder = async (draft) => {
const tid = draft.tenantid || localStorage.getItem('tenantid');
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tid));
const pickup = draft.__pickup;
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
const km = await calculateDrivingDistance(
{ latitude: pickup.latitude, longitude: pickup.longitude },
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
).catch(() => null);
if (km == null) return { error: 'could not measure the distance' };
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
return {
km,
durationMin: getLastRouteDurationMin(),
basePrice: match.baseprice,
total: Number(Number(total).toFixed(2))
};
};
// ---- engine -----------------------------------------------------------------
//
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
// These wrappers keep the order-specific names the panel and the tests use.
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
export const startOrderFlow = () => {
// A client login already belongs to a tenant, so its step is skipped — but
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
// draft is what makes the skip safe.
const tid = localStorage.getItem('tenantid');
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);