dispatch page

This commit is contained in:
2026-06-12 14:45:06 +05:30
parent d8c1517239
commit 5378f2df1f
34 changed files with 4451 additions and 1744 deletions

View File

@@ -16,6 +16,8 @@
* `./queries`, which add caching, dedup, and loading/error state.
*/
import { cleanTenantLocations } from './fiestaApi';
const HASURA_BASE = '/hasura';
/** Tenant whose live data the dashboard displays. */
@@ -185,9 +187,14 @@ export async function getTenantInfo(tenantid: number): Promise<Row | null> {
return firstRow(await hasuraGet('gettenantinfo', { tenantid }));
}
/** /gettenantlocations?tenantid= — physical locations linked to a tenant. */
/**
* /gettenantlocations?tenantid= — physical locations linked to a tenant.
* Piped through the shared cleaner (dedupe + strip test rows) so it matches the
* Fiesta source. The Hasura and Fiesta tenant-location tables share the same DB,
* so both return the same duplicate/junk rows without this.
*/
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await hasuraGet('gettenantlocations', { tenantid }));
return cleanTenantLocations(toRows(await hasuraGet('gettenantlocations', { tenantid })));
}
/** /getcustomersbytenant?tenantid=&limit=&offset= — customers for a tenant. */

View File

@@ -22,14 +22,14 @@ import { firstRow, num, str, type Row } from './fiestaApi';
// ── Backend login config ──────────────────────────────────────────────────────
/**
* Fiesta application login. Routed through the Vite `/fiesta` proxy →
* https://fiesta.nearle.app/live/api/v1/web/users/applogin.
* Fiesta application login — called directly at
* https://fiesta.nearle.app/live/api/v1/web/users/applogin (CORS-enabled).
* Observed shape:
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
* failure: { code: 409, message: "Invalid Email", status: false }
* success: status !== false (Fiesta envelope, optionally with `details`)
*/
const LOGIN_ENDPOINT = '/fiesta/live/api/v1/web/users/applogin';
const LOGIN_ENDPOINT = 'https://fiesta.nearle.app/live/api/v1/web/users/applogin';
/** Request body field names the endpoint expects for the credentials. */
const REQUEST_FIELDS = {
@@ -54,6 +54,10 @@ const RESPONSE_FIELDS = {
email: 'email',
contactno: 'contactno',
userid: 'userid',
// Tenant binding: the merchant this user belongs to. Drives every Fiesta query
// scope (locations, summaries, orders, stock) so a user only sees their own
// tenant's data — not the shared sandbox tenant the constant defaults to.
tenantid: 'tenantid',
// Store binding: a non-admin user is allocated to an app-location via
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
// locationid/locationname are captured when present (often 0/absent on the
@@ -83,6 +87,8 @@ export interface AuthUser {
roleid?: number;
/** Phone number on the user record. */
contactno?: string;
/** The merchant/tenant this user belongs to — scopes every Fiesta query. */
tenantid?: number;
/** The app-location this user is allocated to. */
applocationid?: number;
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
@@ -224,6 +230,7 @@ export function buildAuthUser(row: Row | null, email: string): AuthUser {
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
roleid,
contactno: contactno || undefined,
tenantid: row && row[RESPONSE_FIELDS.tenantid] != null ? num(row[RESPONSE_FIELDS.tenantid]) : undefined,
applocationid:
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
applocation: applocation || undefined,

View File

@@ -1,124 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Sample dispatch data for the Dispatch page.
*
* The live Fiesta deliveries feed (getdeliveries) can come back empty for a given
* day/tenant, which leaves the Dispatch cockpit blank. This module provides a
* realistic sample set — shaped exactly like the rows `DispatchView` consumes —
* so the page demonstrates the rider/zone grouping, waves, and planned-route view.
*
* It is a DEV/DEMO fallback only: DispatchView uses it when the live query returns
* no rows, and clearly labels the header "Sample data" so it never masquerades as
* live. Delete this file (and the fallback in DispatchView) once the live feed is
* reliably populated.
*/
import type { Row } from './fiestaApi';
/** Approx zone centroids in Coimbatore — drop pins are jittered around these. */
const ZONE_COORDS: Record<string, [number, number]> = {
'RS Puram': [11.0069, 76.9498],
'Gandhipuram': [11.0183, 76.9725],
'Peelamedu': [11.0259, 77.001],
'Saibaba Colony': [11.0233, 76.943],
'Singanallur': [10.998, 77.029],
};
const HUB: [number, number] = [11.0045, 76.955]; // Ragul Stores hub (pickup)
/** Build one delivery row in the Fiesta shape DispatchView reads. */
function mk(
deliveryid: number,
userid: number,
ridername: string,
zone: string,
status: string,
assign: string,
kms: number,
profit: number,
step: number,
trip: number,
customer: string,
address: string,
expected: string,
delivered: string,
charge: number,
): Row {
const [zlat, zlon] = ZONE_COORDS[zone] ?? [11.0168, 76.9558];
const droplat = zlat + ((deliveryid % 7) - 3) * 0.0016;
const droplon = zlon + ((deliveryid % 5) - 2) * 0.0018;
return {
orderid: `RS-${deliveryid}`,
deliveryid,
orderheaderid: deliveryid,
tenantname: 'Ragul Stores',
orderstatus: status,
userid,
ridername,
username: ridername,
deliverysuburb: zone,
zone_name: zone,
deliverycustomer: customer,
deliverycontactno: '+91 98430 0' + String(1000 + deliveryid).slice(-4),
deliveryaddress: address,
pickupcustomer: 'Ragul Stores Hub',
kms,
cumulativekms: delivered ? kms : 0,
profit,
step,
trip_number: trip,
assigntime: `2026-06-09 ${assign}:00`,
expecteddeliverytime: expected ? `2026-06-09 ${expected}:00` : '',
deliverytime: delivered ? `2026-06-09 ${delivered}:00` : '',
deliverycharge: charge,
deliveryamt: charge,
droplat,
droplon,
deliverylat: droplat,
deliverylong: droplon,
pickuplat: HUB[0],
pickuplong: HUB[1],
locationid: 1097,
};
}
/** ~16 deliveries across 4 riders + 1 unassigned, 5 zones, and all 3 waves. */
export const MOCK_DELIVERIES: Row[] = [
// ── Suresh Kumar · RS Puram · Morning (trip 1) ──
mk(5001, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:40', 2.4, 38, 1, 1, 'Arun Prasad', '12, Lawley Rd, RS Puram', '07:00', '06:58', 35),
mk(5002, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:45', 3.1, 42, 2, 1, 'Deepa Lakshmi', '45, DB Rd, RS Puram', '07:20', '07:25', 40),
mk(5003, 101, 'Suresh Kumar', 'RS Puram', 'picked', '06:45', 1.8, 30, 3, 1, 'Mohammed Irfan', '8, Bashyakarlu Rd, RS Puram', '07:40', '', 30),
// ── Vignesh R · Gandhipuram / Peelamedu · Afternoon (trip 1) ──
mk(5004, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.0, 35, 1, 1, 'Sangeetha R', '23, 100 Feet Rd, Gandhipuram', '10:00', '09:58', 35),
mk(5005, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.6, 40, 2, 1, 'Bala Subramani', '5, Cross Cut Rd, Gandhipuram', '10:25', '10:30', 38),
mk(5006, 102, 'Vignesh R', 'Peelamedu', 'active', '09:35', 4.2, 55, 3, 1, 'Nithya K', '78, Sathy Rd, Peelamedu', '10:55', '', 50),
mk(5007, 102, 'Vignesh R', 'Peelamedu', 'accepted', '09:35', 3.3, 45, 4, 1, 'Ramesh Babu', '16, Avinashi Rd, Peelamedu', '11:20', '', 42),
// ── Karthik M · Saibaba Colony · Afternoon (trips 1 & 2) ──
mk(5008, 103, 'Karthik M', 'Saibaba Colony', 'delivered', '10:40', 2.9, 41, 1, 1, 'Kavya S', '34, NSR Rd, Saibaba Colony', '11:10', '11:05', 40),
mk(5009, 103, 'Karthik M', 'Saibaba Colony', 'arrived', '10:40', 3.6, 48, 2, 1, 'Vijay Anand', '9, Mettupalayam Rd, Saibaba Colony', '11:35', '', 45),
mk(5010, 103, 'Karthik M', 'Saibaba Colony', 'pending', '10:45', 2.2, 33, 3, 1, 'Meena G', '61, Thadagam Rd, Saibaba Colony', '12:00', '', 32),
mk(5011, 103, 'Karthik M', 'Saibaba Colony', 'cancelled', '11:50', 5.1, 0, 1, 2, 'Hariharan', '2, Ganapathy, Saibaba Colony', '12:20', '', 0),
// ── Priya S · Singanallur / Peelamedu · Evening (trip 1) ──
mk(5012, 104, 'Priya S', 'Singanallur', 'delivered', '16:20', 3.0, 44, 1, 1, 'Divya R', '19, Trichy Rd, Singanallur', '16:50', '16:47', 42),
mk(5013, 104, 'Priya S', 'Singanallur', 'active', '16:20', 4.5, 58, 2, 1, 'Senthil Kumar', '88, Singanallur Main Rd', '17:15', '', 55),
mk(5014, 104, 'Priya S', 'Singanallur', 'picked', '16:25', 2.7, 39, 3, 1, 'Anitha M', '7, Ondipudur, Singanallur', '17:40', '', 38),
mk(5015, 104, 'Priya S', 'Peelamedu', 'accepted', '16:25', 3.9, 50, 4, 1, 'Gokul Raj', '52, Hope College, Peelamedu', '18:05', '', 48),
// ── Unassigned · Peelamedu · Evening ──
mk(5016, 0, '', 'Peelamedu', 'pending', '17:25', 2.3, 34, 1, 1, 'Lakshmi Narayanan', '30, Lakshmi Mills, Peelamedu', '18:30', '', 33),
];
/** Sample active rider fleet (DispatchView only reads the count). */
export const MOCK_RIDERS: Row[] = [
{ userid: 101, firstname: 'Suresh', lastname: 'Kumar', contactno: '+91 98430 01101', starttime: '2026-06-09 06:30:00' },
{ userid: 102, firstname: 'Vignesh', lastname: 'R', contactno: '+91 98430 01102', starttime: '2026-06-09 09:15:00' },
{ userid: 103, firstname: 'Karthik', lastname: 'M', contactno: '+91 98430 01103', starttime: '2026-06-09 10:20:00' },
{ userid: 104, firstname: 'Priya', lastname: 'S', contactno: '+91 98430 01104', starttime: '2026-06-09 16:00:00' },
{ userid: 105, firstname: 'Mahesh', lastname: 'V', contactno: '+91 98430 01105', starttime: '' },
];

View File

@@ -9,16 +9,16 @@
* REST tab). This is the operational backend: order/delivery/location summaries,
* the deliveries board, riders, stock statements, and customers.
*
* Requests go through the Vite dev proxy at `/fiesta/*`, which forwards to
* `https://fiesta.nearle.app/*` (see vite.config.ts). Fiesta is CORS-enabled and
* needs no auth header for these read endpoints.
* Requests go directly to `https://fiesta.nearle.app/*` — Fiesta is CORS-enabled
* and needs no auth header for these read endpoints, so no dev proxy is required.
*
* This sits alongside `./api` (the Hasura/workolik REST surface the dashboard
* uses). Components should call the TanStack hooks in `./fiestaQueries`, not
* these functions directly.
*/
const FIESTA_BASE = '/fiesta/live/api/v1/web';
const FIESTA_BASE = 'https://fiesta.nearle.app/live/api/v1/web';
const FIESTA_MOB_BASE = 'https://fiesta.nearle.app/live/api/v1/mob';
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
export const FIESTA_TENANT_ID = 1087;
@@ -29,6 +29,19 @@ export const FIESTA_PRIMARY_LOCATION_ID = 1097;
export type Row = Record<string, unknown>;
type QueryParams = Record<string, string | number | undefined | null>;
/**
* The exact payload the nearledaily consumer app expects when its in-app scanner
* reads a store QR: a JSON object `{"tenantid":N,"locationid":N}`. The app parses
* this and resolves the outlet from it.
*
* IMPORTANT: it must be this JSON shape, NOT a URL — the app rejects a URL with
* "invalid QR code content". Keep it to exactly these two keys to match the app's
* schema; extra keys risk strict-schema rejection on the app side.
*/
export function buildStoreQrPayload(opts: { tenantid: number; locationid: number }): string {
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
}
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
@@ -112,13 +125,14 @@ export interface FiestaOrderSummary {
tenantname?: string;
}
/** /orders/getordersummary?tenantid=&fromdate=&todate= — flat order counts. */
/** /orders/getordersummary?tenantid=&locationid=&fromdate=&todate= — flat order counts. */
export async function getOrderSummary(
tenantid: number,
fromdate: string,
todate: string,
locationid?: number,
): Promise<FiestaOrderSummary | null> {
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, fromdate, todate }));
const row = firstRow<Row>(await fiestaGet('orders/getordersummary', { tenantid, locationid, fromdate, todate }));
if (!row) return null;
return {
total: num(row.total),
@@ -162,21 +176,27 @@ export async function getOrderInsight(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
}
/** /orders/getorders?tenantid=&status=&fromdate=&todate=&pageno=&pagesize= — orders board. */
/** /orders/getorders?tenantid=&locationid=&applocationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — orders board. */
export async function getOrders(opts: {
tenantid: number;
status: string;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('orders/getorders', {
tenantid: opts.tenantid,
locationid: opts.locationid,
applocationid: opts.applocationid,
status: opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
@@ -185,7 +205,15 @@ export async function getOrders(opts: {
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid }));
let cleanId = String(orderheaderid).trim();
if (cleanId.toUpperCase().startsWith('DLV-')) {
cleanId = cleanId.substring(4);
}
cleanId = cleanId.split('-')[0];
const numericId = Number(cleanId);
const finalId = Number.isInteger(numericId) && numericId > 0 ? numericId : orderheaderid;
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid: finalId }));
}
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
@@ -221,17 +249,19 @@ export interface FiestaDeliverySummary {
cancelled: number;
}
/** /deliveries/deliverysummary?tenantid=&applocationid=&fromdate=&todate= — dispatch counts. */
/** /deliveries/deliverysummary?tenantid=&applocationid=&locationid=&fromdate=&todate= — dispatch counts. */
export async function getDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
locationid?: number;
fromdate: string;
todate: string;
}): Promise<FiestaDeliverySummary | null> {
const row = firstRow<Row>(
await fiestaGet('deliveries/deliverysummary', {
tenantid: opts.tenantid,
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
applocationid: opts.applocationid, // only sent when provided (no forced default)
locationid: opts.locationid,
fromdate: opts.fromdate,
todate: opts.todate,
}),
@@ -250,19 +280,40 @@ export async function getDeliverySummary(opts: {
};
}
/** /deliveries/getdeliveries?tenantid=&fromdate=&todate= — the master deliveries board. */
/** /deliveries/getdeliveries?tenantid=&applocationid=&locationid=&status=&fromdate=&todate=&keyword=&pageno=&pagesize= — the master deliveries board. */
export async function getDeliveries(opts: {
tenantid: number;
fromdate: string;
todate: string;
status?: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
const rows = toRows(
await fiestaGet('deliveries/getdeliveries', {
tenantid: opts.tenantid,
// NOTE: do NOT send `locationid` to getdeliveries — the backend's locationid
// filter on THIS endpoint is broken: passing a real outlet id returns []
// (it doesn't match against the row's own `locationid`), even though
// deliverysummary honours the same id and the rows clearly carry it. So we
// fetch tenant-wide here and scope by locationid client-side below; the KPI
// strip (deliverysummary) keeps using the working server-side filter.
applocationid: opts.applocationid,
// The backend treats `status` as a LITERAL orderstatus filter — passing
// 'all' matches nothing (returns []). Send empty to fetch every status and
// let the board filter client-side by its status tabs.
status: !opts.status || opts.status === 'all' ? '' : opts.status,
fromdate: opts.fromdate,
todate: opts.todate,
keyword: opts.keyword,
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 200,
}),
);
return opts.locationid ? rows.filter((r) => num(r.locationid) === opts.locationid) : rows;
}
/** /deliveries/getdeliveryinsight?tenantid= — daily delivery insight. */
@@ -312,35 +363,214 @@ export async function getFleetSummary(opts: {
);
}
/** `YYYY-MM-DD HH:mm:ss` — the timestamp format the delivery endpoints expect. */
function nowStamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/**
* Build a delivery record from a getorders row for POST /createdeliveries. The
* backend keys the order (orderheaderid) and the rider (userid), copies the
* pickup/drop snapshot, and must carry the SAME tenant/partner/applocation as
* the order so the assignment is valid. Order field names map 1:1 (Go's JSON
* decode is case-insensitive, so `pickupaddress` satisfies `Pickupaddress`).
*/
function deliveryFromOrder(o: Row, userid: number, assigntime: string): Row {
return {
orderheaderid: num(o.orderheaderid),
orderid: str(o.orderid),
applocationid: num(o.applocationid),
configid: num(o.configid) || 1,
partnerid: num(o.partnerid),
tenantid: num(o.tenantid),
moduleid: num(o.moduleid),
locationid: num(o.locationid),
categoryid: num(o.categoryid),
subcategoryid: num(o.subcategoryid),
userid, // the assigned rider
customerid: num(o.customerid),
orderstatus: 'pending',
assigntime,
// The Orders API exposes the scheduled delivery date as `deliverytime` (there
// is no `deliverydate` on an order row). Copy it through so the new delivery
// lands in the Deliveries board's date window — falling back to the order date
// and finally the assign timestamp so the row is never written date-less
// (a date-less delivery is excluded by getdeliveries' from/to filter).
deliverydate: str(o.deliverydate) || str(o.deliverytime) || str(o.orderdate) || assigntime,
itemcount: num(o.itemcount),
orderamount: num(o.orderamount) || num(o.deliveryamt),
deliveryamt: num(o.deliveryamt),
deliverycharges: num(o.deliverycharge) || num(o.deliverycharges),
paymenttype: num(o.paymenttype),
ordernotes: str(o.ordernotes),
pickupcustomer: str(o.pickupcustomer) || str(o.tenantname),
pickupcontactno: str(o.pickupcontactno),
pickupaddress: str(o.pickupaddress),
pickuplocationid: num(o.pickuplocationid),
pickuplat: str(o.pickuplat),
pickuplon: str(o.pickuplong) || str(o.pickuplon),
deliverycustomerid: num(o.deliverycustomerid),
deliverylocationid: num(o.deliverylocationid),
deliverycustomer: str(o.deliverycustomer),
deliverycontactno: str(o.deliverycontactno),
deliveryaddress: str(o.deliveryaddress),
deliverylat: str(o.deliverylat),
deliverylong: str(o.deliverylong),
};
}
/**
* Assign a rider to one or more orders — the CORRECT flow per the backend:
* • orders with no delivery yet (`deliveryid == 0`, i.e. freshly created) →
* POST /deliveries/createdeliveries (one batched call). This creates the
* delivery, enqueues it, AND flips the order to `pending`.
* • orders that already have a delivery → PUT /deliveries/updatedelivery to
* re-point the rider.
* The rider (userid) MUST belong to the same tenant/partner as the orders, or
* the backend rejects the assignment — that scoping is enforced on the rider
* list (see getRiders' partnerid).
*/
export async function assignRiderToOrders(
userid: number,
orders: Row[],
): Promise<{ ok: number; failed: number; total: number }> {
const assigntime = nowStamp();
const toCreate = orders.filter((o) => !num(o.deliveryid));
const toUpdate = orders.filter((o) => num(o.deliveryid));
let ok = 0;
let failed = 0;
if (toCreate.length) {
try {
await fiestaSend('deliveries/createdeliveries', 'POST', toCreate.map((o) => deliveryFromOrder(o, userid, assigntime)));
ok += toCreate.length;
} catch {
failed += toCreate.length;
}
}
if (toUpdate.length) {
const results = await Promise.allSettled(
toUpdate.map((o) =>
fiestaSend('deliveries/updatedelivery', 'PUT', {
userid,
deliveryid: num(o.deliveryid),
orderheaderid: num(o.orderheaderid),
orderstatus: 'pending',
assigntime,
}),
),
);
ok += results.filter((r) => r.status === 'fulfilled').length;
failed += results.filter((r) => r.status === 'rejected').length;
}
return { ok, failed, total: orders.length };
}
// ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════
/** /partners/getriders?applocationid=&tenantid= — active rider fleet. */
/**
* /partners/getriders?applocationid=&tenantid=&partnerid= — active rider fleet.
* Scoped by tenant AND partner: a rider belongs to one tenant/partner, so an
* order can only be assigned to a rider sharing its partnerid. Passing the
* order's partnerid keeps the assignable list correct (an out-of-tenant rider
* simply won't appear, which is the intended guard).
*/
export async function getRiders(opts: {
applocationid?: number;
tenantid: number;
partnerid?: number;
}): Promise<Row[]> {
return toRows(
await fiestaGet('partners/getriders', {
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
tenantid: opts.tenantid,
partnerid: opts.partnerid,
}),
);
}
/** /partners/getridershifts?applocationid= — rider shift records. */
export async function getRiderShifts(applocationid: number = FIESTA_APPLOCATION_ID): Promise<Row[]> {
return toRows(await fiestaGet('partners/getridershifts', { applocationid }));
return toRows(await fiestaGet('partners/getridershifts/', { applocationid }));
}
// ════════════════════════════════════════════════════════════════════════════
// TENANTS / CUSTOMERS
// ════════════════════════════════════════════════════════════════════════════
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant. */
/**
* Throwaway/test email providers. A location whose contact email is on one of
* these is a sandbox record, never a real outlet — used to drop test data.
*/
const DISPOSABLE_EMAIL_DOMAINS = new Set([
'mailinator.com',
'mailinator.net',
'example.com',
'example.org',
'test.com',
'yopmail.com',
'guerrillamail.com',
'10minutemail.com',
]);
/**
* The tenant-locations endpoint for some tenants returns junk: the primary
* outlet duplicated several times, plus orphan test records geocoded to random
* countries (e.g. "Deborah Lara, Spain", "power, Ireland") with throwaway
* emails. This strips both so the registry/inventory show only real outlets.
*
* The filter is self-calibrating (no hardcoded names/ids): it derives the
* tenant's operating region from the most common state among its outlets, then
* drops rows that either use a disposable email or sit outside that region. If
* the region can't be established (no state data), nothing is region-filtered —
* we'd rather show an extra row than hide a genuine outlet.
*/
export function cleanTenantLocations(rows: Row[]): Row[] {
// 1. Dedupe by locationid — the API repeats the primary outlet.
const seen = new Set<number>();
const deduped = rows.filter((r) => {
const id = num(r.locationid);
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
// 2. Find the tenant's home region (plurality of `state`).
const stateCounts = new Map<string, number>();
for (const r of deduped) {
const st = str(r.state).trim().toLowerCase();
if (st) stateCounts.set(st, (stateCounts.get(st) ?? 0) + 1);
}
let homeState = '';
let max = 0;
for (const [st, c] of stateCounts) {
if (c > max) {
max = c;
homeState = st;
}
}
// 3. Drop disposable-email rows and out-of-region rows.
return deduped.filter((r) => {
const emailDomain = (str(r.email).split('@')[1] ?? '').trim().toLowerCase();
if (emailDomain && DISPOSABLE_EMAIL_DOMAINS.has(emailDomain)) return false;
if (homeState) {
const st = str(r.state).trim().toLowerCase();
if (st && st !== homeState) return false;
}
return true;
});
}
/** /tenants/gettenantlocations?tenantid= — outlet locations for a tenant (test rows stripped). */
export async function getTenantLocations(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('tenants/gettenantlocations', { tenantid }));
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', { tenantid })));
}
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
@@ -360,7 +590,27 @@ export async function getAllTenants(opts: {
);
}
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= */
/**
* Collapse the gettenantcustomers rows to one per customer. The endpoint returns
* one row per saved DELIVERY ADDRESS (each carries its own deliverylocationid /
* address), so a customer with several addresses repeats many times. Key by
* customerid (fall back to contactno), preferring the row flagged primaryaddress;
* rows with no identity at all are kept as-is so nothing is silently dropped.
*/
export function dedupeCustomers(rows: Row[]): Row[] {
const byCustomer = new Map<string, Row>();
for (const r of rows) {
const cid = num(r.customerid);
const key = cid ? `c${cid}` : (str(r.contactno) ? `p${str(r.contactno)}` : `x${byCustomer.size}`);
const existing = byCustomer.get(key);
if (!existing || (num(r.primaryaddress) && !num(existing.primaryaddress))) {
byCustomer.set(key, r);
}
}
return [...byCustomer.values()];
}
/** /customers/gettenantcustomers?tenantid=&locationid=&pageno=&pagesize=&keyword= (deduped per customer). */
export async function getTenantCustomers(opts: {
tenantid: number;
locationid: number;
@@ -368,7 +618,7 @@ export async function getTenantCustomers(opts: {
pageno?: number;
pagesize?: number;
}): Promise<Row[]> {
return toRows(
return dedupeCustomers(toRows(
await fiestaGet('customers/gettenantcustomers', {
tenantid: opts.tenantid,
locationid: opts.locationid,
@@ -376,7 +626,7 @@ export async function getTenantCustomers(opts: {
pageno: opts.pageno ?? 1,
pagesize: opts.pagesize ?? 20,
}),
);
));
}
// ════════════════════════════════════════════════════════════════════════════
@@ -540,8 +790,13 @@ export interface CreateUserInput {
lastname?: string;
email: string;
contactno: string;
password: string;
/** Optional — merchant_web's create form doesn't collect one. */
password?: string;
roleid: number;
/** Role config (the selected role's configid) — matches merchant_web's create payload. */
configid?: number;
/** Business module id (merchant_web sends the logged-in user's; 0 when absent). */
moduleid?: number;
dialcode?: string;
pin?: number;
address?: string;
@@ -549,6 +804,10 @@ export interface CreateUserInput {
city?: string;
state?: string;
postcode?: string;
latitude?: string;
longitude?: string;
/** Rider shift (only meaningful for rider-role users). */
shiftid?: number;
tenantid: number;
locationid?: number;
applocationid?: number;
@@ -561,17 +820,22 @@ export async function createUser(input: CreateUserInput): Promise<Row> {
authname: input.email,
firstname: input.firstname,
lastname: input.lastname ?? '',
password: input.password,
password: input.password ?? '',
email: input.email,
dialcode: input.dialcode ?? '+91',
contactno: input.contactno,
roleid: input.roleid,
configid: input.configid ?? 15,
moduleid: input.moduleid ?? 0,
pin: input.pin ?? 0,
address: input.address ?? '',
suburb: input.suburb ?? '',
city: input.city ?? '',
state: input.state ?? '',
postcode: input.postcode ?? '',
latitude: input.latitude ?? '',
longitude: input.longitude ?? '',
shiftid: input.shiftid ?? 0,
tenantid: input.tenantid,
locationid: input.locationid ?? 0,
applocationid: input.applocationid ?? FIESTA_APPLOCATION_ID,
@@ -597,3 +861,56 @@ export interface UpdateUserInput {
export async function updateUser(input: UpdateUserInput): Promise<Row> {
return fiestaSend<Row>('users/update', 'PUT', input);
}
export interface CreateTenantInput {
tenantname: string;
companyname: string;
primarycontact: string;
primaryemail: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
approved?: number;
status?: string;
}
/** POST /tenants/createtenantuser — Onboard a new tenant and create their admin user. */
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
const res = await fetch(`${FIESTA_MOB_BASE}/tenants/createtenantuser`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = (await res.json().catch(() => null)) as { message?: string; status?: boolean } | null;
if (!res.ok || (json && json.status === false)) {
throw new Error(json?.message || `Tenant onboarding failed: ${res.status}`);
}
return json as Row;
}
export interface CreateTenantLocationInput {
tenantid: number;
locationname: string;
address?: string;
suburb?: string;
city?: string;
state?: string;
postcode?: string;
contactno?: string;
email?: string;
opentime?: string;
closetime?: string;
deliverymins?: number;
deliveryradius?: number;
latitude?: string;
longitude?: string;
status?: string;
}
/** POST /tenants/createtenantlocation — Create a new store location under a tenant. */
export async function createTenantLocation(input: CreateTenantLocationInput): Promise<Row> {
return fiestaSend<Row>('tenants/createtenantlocation', 'POST', input);
}

View File

@@ -122,3 +122,47 @@ export function deliveryRowToOrder(row: Row): CustomerOrder {
locationid: num(row.locationid)
};
}
/**
* orders-board row (from /orders/getorders) -> CustomerOrder card.
* The orders API uses different field names than the deliveries board.
*/
export function orderRowToOrder(row: Row): CustomerOrder {
// Amount: orders API returns orderamount / ordervalue / collectionamt
const amount = num(row.ordervalue) || num(row.orderamount) || num(row.collectionamt);
// Rider: may come from the linked delivery record
const rider = str(row.ridername) || str(row.ridernames) || '';
// Customer: orders use different field names
const customerName =
str(row.deliverycustomer) ||
str(row.customername) ||
str(row.firstname) + (str(row.lastname) ? ` ${str(row.lastname)}` : '') ||
'Customer';
// Address: drop address (where order is delivered)
const address =
str(row.deliveryaddress) ||
str(row.deliverysuburb) ||
str(row.pickupaddress) ||
'Address unavailable';
// Hub: store that fulfilled the order
const hub =
str(row.locationname) ||
str(row.pickupcustomer) ||
str(row.tenantname) ||
`Location ${str(row.locationid)}`;
return {
id: str(row.orderid) || `ORD-${str(row.orderheaderid)}`,
customerName: customerName.trim() || 'Customer',
phone: str(row.contactno) || str(row.deliverycontactno) || '—',
address,
items: [],
amount,
time: shortTime(row.orderdate || row.deliverytime || row.createdat),
status: mapOrderStatus(str(row.orderstatus)),
assignedRider: rider || 'Pending Assignment',
hub,
itemCount: num(row.itemcount) || num(row.quantity),
locationid: num(row.locationid),
};
}

View File

@@ -46,12 +46,17 @@ import {
getUserById,
createUser,
updateUser,
assignRiderToOrders,
CreateUserInput,
createTenantUser,
createTenantLocation,
CreateTenantInput,
CreateTenantLocationInput,
} from './fiestaApi';
export const fiestaKeys = {
orderSummary: (tenantid: number, fromdate: string, todate: string) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate] as const,
orderSummary: (tenantid: number, fromdate: string, todate: string, locationid?: number) =>
['fiesta', 'orderSummary', tenantid, fromdate, todate, locationid ?? 0] as const,
locationSummary: (tenantid: number) => ['fiesta', 'locationSummary', tenantid] as const,
orderInsight: (tenantid: number) => ['fiesta', 'orderInsight', tenantid] as const,
orders: (params: Record<string, unknown>) => ['fiesta', 'orders', params] as const,
@@ -60,7 +65,9 @@ export const fiestaKeys = {
deliveryInsight: (tenantid: number) => ['fiesta', 'deliveryInsight', tenantid] as const,
riders: (params: Record<string, unknown>) => ['fiesta', 'riders', params] as const,
riderShifts: (applocationid: number) => ['fiesta', 'riderShifts', applocationid] as const,
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', tenantid] as const,
// v2: bumped when test-row filtering was added to getTenantLocations so any
// warm cache holding the old unfiltered (duplicated/junk) rows is bypassed.
tenantLocations: (tenantid: number) => ['fiesta', 'tenantLocations', 'v2', tenantid] as const,
allTenants: (params: Record<string, unknown>) => ['fiesta', 'allTenants', params] as const,
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
@@ -79,10 +86,10 @@ export const fiestaKeys = {
};
// ── Orders ──────────────────────────────────────────────────────────────────
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string) {
export function useFiestaOrderSummary(tenantid: number = FIESTA_TENANT_ID, fromdate: string, todate: string, locationid?: number) {
return useQuery({
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate),
queryFn: () => getOrderSummary(tenantid, fromdate, todate),
queryKey: fiestaKeys.orderSummary(tenantid, fromdate, todate, locationid),
queryFn: () => getOrderSummary(tenantid, fromdate, todate, locationid),
enabled: Boolean(tenantid && fromdate && todate),
});
}
@@ -108,6 +115,9 @@ export function useFiestaOrders(opts: {
status: string;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
@@ -118,10 +128,61 @@ export function useFiestaOrders(opts: {
});
}
/**
* Fetches orders across all statuses for a given date range by firing one
* request per status in parallel and merging the results. This is needed
* because the /orders/getorders API requires an explicit status param and
* returns an empty array when status is blank or 'all'.
*/
export function useFiestaAllOrders(opts: {
tenantid: number;
fromdate: string;
todate: string;
locationid?: number;
applocationid?: number;
keyword?: string;
}) {
return useQuery({
queryKey: ['fiesta', 'allOrders', opts],
queryFn: async () => {
const statuses = ['created', 'pending', 'processing', 'delivered', 'cancelled'];
const results = await Promise.all(
statuses.map(status =>
getOrders({
tenantid: opts.tenantid,
status,
fromdate: opts.fromdate,
todate: opts.todate,
locationid: opts.locationid,
applocationid: opts.applocationid,
keyword: opts.keyword,
pagesize: 100,
}).catch(() => [] as Row[])
)
);
// Merge and deduplicate by orderid/orderheaderid
const merged: Row[] = [];
const seen = new Set<string>();
for (const list of results) {
for (const row of list) {
const id = String(row.orderid || row.orderheaderid || Math.random());
if (!seen.has(id)) {
seen.add(id);
merged.push(row);
}
}
}
return merged;
},
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
// ── Deliveries ────────────────────────────────────────────────────────────────
export function useFiestaDeliverySummary(opts: {
tenantid: number;
applocationid?: number;
locationid?: number;
fromdate: string;
todate: string;
}) {
@@ -132,7 +193,17 @@ export function useFiestaDeliverySummary(opts: {
});
}
export function useFiestaDeliveries(opts: { tenantid: number; fromdate: string; todate: string }) {
export function useFiestaDeliveries(opts: {
tenantid: number;
fromdate: string;
todate: string;
status?: string;
locationid?: number;
applocationid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.deliveries(opts),
queryFn: () => getDeliveries(opts),
@@ -148,8 +219,28 @@ export function useFiestaDeliveryInsight(tenantid: number = FIESTA_TENANT_ID) {
});
}
/**
* Bulk-assign one rider to many orders (the Orders board's multi-select assign).
* Fires one updatedelivery per row in parallel, tolerates partial failure, and
* refreshes the orders + deliveries lists on completion.
*/
export function useFiestaAssignRider() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { userid: number; orders: Row[] }) => assignRiderToOrders(input.userid, input.orders),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'orders'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'orderSummary'] });
// Refresh the Deliveries board AND its KPI summary so a freshly-assigned
// order shows up on the deliveries page immediately (table + count cards).
qc.invalidateQueries({ queryKey: ['fiesta', 'deliveries'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'deliverySummary'] });
},
});
}
// ── Partners / Riders ─────────────────────────────────────────────────────────
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number }) {
export function useFiestaRiders(opts: { applocationid?: number; tenantid: number; partnerid?: number }) {
return useQuery({
queryKey: fiestaKeys.riders(opts),
queryFn: () => getRiders(opts),
@@ -409,6 +500,28 @@ export function useFiestaUpdateUser() {
});
}
/** Create a new tenant and admin user, then refresh tenants list on success. */
export function useFiestaCreateTenant() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTenantInput) => createTenantUser(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fiesta', 'allTenants'] }),
});
}
/** Create a new tenant location, then refresh tenant locations list on success. */
export function useFiestaCreateLocation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTenantLocationInput) => createTenantLocation(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['fiesta', 'tenantLocations'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'locationSummary'] });
},
});
}
// ── Auth ──────────────────────────────────────────────────────────────────────
/**
* Verify login credentials against the Fiesta web-login endpoint. A mutation

View File

@@ -0,0 +1,81 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The shared "store catalogue" — the products the **admin** has selected from the
* global catalogue (each with a quantity). This curated list is what **store
* users** see and pick from. It's the design-stage bridge between the admin
* catalogue page (InventoryView) and the user catalogue page (StoreCatalogView).
*
* Persisted in localStorage so the flow is fully demonstrable on one device; it
* syncs live across tabs/pages via a storage event. The backend equivalents
* (once built) are:
* • admin curates → POST /products/createproductlocation (productid, qty, …)
* • user reads → GET /products/getlocationproducts
* Swap `read`/`write` for those calls when the API is ready; the hook API stays.
*/
import { useEffect, useState } from 'react';
export interface StoreCatalogueItem {
productid: string;
name: string;
image: string;
category: string;
sku?: string;
price: number;
unit: string;
/** Quantity the admin intends to stock for this product. */
qty: number;
}
const KEY = 'nearledaily.storeCatalogue';
const EVENT = 'nearledaily:storeCatalogue';
function read(): StoreCatalogueItem[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as StoreCatalogueItem[]) : [];
} catch {
return [];
}
}
function write(items: StoreCatalogueItem[]): void {
try {
localStorage.setItem(KEY, JSON.stringify(items));
} catch {
/* storage unavailable */
}
// Notify listeners in this tab (storage event only fires in OTHER tabs).
window.dispatchEvent(new Event(EVENT));
}
/**
* Live view of the store catalogue + curation helpers. Re-renders whenever the
* catalogue changes (this tab or another).
*/
export function useStoreCatalogue() {
const [items, setItems] = useState<StoreCatalogueItem[]>(read);
useEffect(() => {
const sync = () => setItems(read());
window.addEventListener(EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
const has = (id: string) => items.some((i) => i.productid === id);
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
const add = (item: StoreCatalogueItem) => write([...read().filter((i) => i.productid !== item.productid), item]);
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
const setQty = (id: string, qty: number) =>
write(read().map((i) => (i.productid === id ? { ...i, qty: Math.max(1, Math.round(qty) || 1) } : i)));
return { items, has, getQty, add, remove, setQty };
}