The consumer app resolves a storefront by scanning a QR that encodes
{tenantid, locationid}. Store Onboarding was creating the location but never
surfacing its QR, so operators had to hunt for it later in the store detail
view. Now that the backend returns the created location's locationid in the
onboarding response, render the existing StoreQRView component directly in
the "Store Branch Active!" success panel (both onboarding UI variants) so the
printable QR is available the moment the branch is created.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1315 lines
45 KiB
TypeScript
1315 lines
45 KiB
TypeScript
/**
|
|
* @license
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Fiesta REST client — the merchant-facing `live/api/v1/web/*` surface served by
|
|
* https://fiesta.nearle.app (documented at developer.nearledaily.com under the
|
|
* REST tab). This is the operational backend: order/delivery/location summaries,
|
|
* the deliveries board, riders, stock statements, and customers.
|
|
*
|
|
* 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 = import.meta.env.VITE_FIESTA_URL || 'https://fiesta.nearle.app/live/api/v1/web';
|
|
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'https://fiesta.nearle.app/live/api/v1/mob';
|
|
|
|
/** Tenant / location scope shared by the merchant console. */
|
|
export const FIESTA_TENANT_ID = 1135;
|
|
export const FIESTA_APPLOCATION_ID = 1;
|
|
/** Primary outlet for this tenant — the one carrying live orders/stock. */
|
|
export const FIESTA_PRIMARY_LOCATION_ID = 1170;
|
|
|
|
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]) => {
|
|
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
|
|
// keep empty strings and only drop undefined/null.
|
|
if (v !== undefined && v !== null) qs.append(k, String(v));
|
|
});
|
|
const query = qs.toString();
|
|
const res = await fetch(`${FIESTA_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function fiestaSend<T = unknown>(
|
|
endpoint: string,
|
|
method: 'POST' | 'PUT' | 'DELETE',
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const res = await fetch(`${FIESTA_BASE}/${endpoint}`, {
|
|
method,
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
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 || `Fiesta ${endpoint} failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return json as T;
|
|
}
|
|
|
|
/**
|
|
* Fiesta envelopes responses as `{ code, details, message, status }`. `details`
|
|
* is usually an array of rows, sometimes a single object, sometimes null.
|
|
*/
|
|
export function toRows<T = Row>(json: unknown): T[] {
|
|
if (Array.isArray(json)) return json as T[];
|
|
if (json && typeof json === 'object') {
|
|
const d = (json as { details?: unknown }).details;
|
|
if (Array.isArray(d)) return d as T[];
|
|
if (d && typeof d === 'object') return [d as T];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function firstRow<T = Row>(json: unknown): T | null {
|
|
const d = (json as { details?: unknown })?.details;
|
|
if (Array.isArray(d)) return (d.length ? (d[0] as T) : null);
|
|
if (d && typeof d === 'object') return d as T;
|
|
return null;
|
|
}
|
|
|
|
export function num(v: unknown): number {
|
|
const n = typeof v === 'number' ? v : Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
export const str = (v: unknown): string => (v == null ? '' : String(v));
|
|
|
|
/** Fiesta date params want a bare `YYYY-MM-DD`. */
|
|
export const ymd = (d: Date) =>
|
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// ORDERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface FiestaOrderSummary {
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
processing: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
tenantid?: number;
|
|
tenantname?: string;
|
|
}
|
|
|
|
export interface FiestaLocationRevenue {
|
|
locationid: number;
|
|
locationname: string;
|
|
revenue: number;
|
|
}
|
|
|
|
export interface FiestaRevenueSummary {
|
|
tenantid: number;
|
|
tenantname: string;
|
|
overallrevenue: number;
|
|
locationrevenue: FiestaLocationRevenue[];
|
|
}
|
|
|
|
/** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */
|
|
export async function getRevenueSummary(opts: {
|
|
tenantid: number;
|
|
fromdate?: string;
|
|
todate?: string;
|
|
locationid?: number;
|
|
}) {
|
|
const res = await fiestaGet('orders/getrevenuesummary', opts);
|
|
return firstRow<{
|
|
tenantid: number;
|
|
tenantname: string;
|
|
overallrevenue: number;
|
|
locationrevenue: Array<{
|
|
locationid: number;
|
|
locationname: string;
|
|
revenue: number;
|
|
}>;
|
|
}>(res);
|
|
}
|
|
|
|
|
|
|
|
/** /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,
|
|
locationid,
|
|
applocationid: locationid, // send both to bypass backend bugs
|
|
fromdate,
|
|
todate
|
|
}));
|
|
if (!row) return null;
|
|
return {
|
|
total: num(row.total),
|
|
created: num(row.created),
|
|
pending: num(row.pending),
|
|
processing: num(row.processing),
|
|
delivered: num(row.delivered),
|
|
cancelled: num(row.cancelled),
|
|
tenantid: row.tenantid != null ? num(row.tenantid) : undefined,
|
|
tenantname: typeof row.tenantname === 'string' ? row.tenantname : undefined,
|
|
};
|
|
}
|
|
|
|
export interface FiestaLocationSummary {
|
|
locationid: number;
|
|
locationname: string;
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
processing: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
}
|
|
|
|
/** /orders/getlocationsummary?tenantid=&fromdate=&todate= — per-outlet order rollup. */
|
|
export async function getLocationSummary(tenantid: number, fromdate?: string, todate?: string): Promise<FiestaLocationSummary[]> {
|
|
return toRows<Row>(await fiestaGet('orders/getlocationsummary', { tenantid, fromdate, todate })).map((r) => ({
|
|
locationid: num(r.locationid),
|
|
locationname: str(r.locationname),
|
|
total: num(r.total),
|
|
created: num(r.created),
|
|
pending: num(r.pending),
|
|
processing: num(r.processing),
|
|
delivered: num(r.delivered),
|
|
cancelled: num(r.cancelled),
|
|
}));
|
|
}
|
|
|
|
/** /orders/getorderinsight?tenantid= — per-location monthly order counts. */
|
|
export async function getOrderInsight(tenantid: number): Promise<Row[]> {
|
|
return toRows(await fiestaGet('orders/getorderinsight', { tenantid }));
|
|
}
|
|
|
|
/** /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,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /orders/gettimeseries?tenantid=&locationid=&granularity=&fromdate=&todate= — time-series chart data */
|
|
export async function getTimeSeries(opts: {
|
|
tenantid: number;
|
|
granularity: 'day' | 'month' | 'year';
|
|
fromdate: string;
|
|
todate: string;
|
|
locationid?: number;
|
|
}) {
|
|
const res = await fiestaGet('orders/gettimeseries', opts);
|
|
return toRows<{
|
|
label: string;
|
|
orders: number;
|
|
revenue: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
activeskus: number;
|
|
}>(res);
|
|
}
|
|
|
|
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
|
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
|
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. */
|
|
export async function getCustomerOrders(opts: {
|
|
customerid: number | string;
|
|
status?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('orders/getorders', {
|
|
customerid: opts.customerid,
|
|
status: opts.status ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// DELIVERIES
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface FiestaDeliverySummary {
|
|
total: number;
|
|
created: number;
|
|
pending: number;
|
|
accepted: number;
|
|
arrived: number;
|
|
picked: number;
|
|
active: number;
|
|
delivered: number;
|
|
cancelled: number;
|
|
}
|
|
|
|
/** /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, // only sent when provided (no forced default)
|
|
locationid: opts.locationid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
if (!row) return null;
|
|
return {
|
|
total: num(row.total),
|
|
created: num(row.created),
|
|
pending: num(row.pending),
|
|
accepted: num(row.accepted),
|
|
arrived: num(row.arrived),
|
|
picked: num(row.picked),
|
|
active: num(row.active),
|
|
delivered: num(row.delivered),
|
|
cancelled: num(row.cancelled),
|
|
};
|
|
}
|
|
|
|
/** /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[]> {
|
|
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. */
|
|
export async function getDeliveryInsight(tenantid: number): Promise<Row[]> {
|
|
return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid }));
|
|
}
|
|
|
|
/** /deliveries/getdeliveryreport?tenantid=&applocationid=&partnerid=&userid=&fromdate=&todate= —
|
|
* deliveries financial report summary (per the endpoint sheet). */
|
|
export async function getDeliveryReport(opts: {
|
|
tenantid: number;
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
userid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('deliveries/getdeliveryreport', {
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
partnerid: opts.partnerid,
|
|
userid: opts.userid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getfleetsummary?applocationid=&partnerid=&tenantid=&fromdate=&todate= —
|
|
* fleet rider summary metrics (per the endpoint sheet). */
|
|
export async function getFleetSummary(opts: {
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
tenantid: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getfleetsummary', {
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
partnerid: opts.partnerid,
|
|
tenantid: opts.tenantid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** `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=&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 }));
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// TENANTS / CUSTOMERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* 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=&userid= — outlet locations for a tenant (test rows stripped). */
|
|
export async function getTenantLocations(tenantid: number, userid?: number): Promise<Row[]> {
|
|
const params: Record<string, any> = { tenantid };
|
|
if (userid) params.userid = userid;
|
|
return cleanTenantLocations(toRows(await fiestaGet('tenants/gettenantlocations', params)));
|
|
}
|
|
|
|
/** /tenants/getalltenants?applocationid=&status=&pageno=&pagesize= — active tenants. */
|
|
export async function getAllTenants(opts: {
|
|
applocationid?: number;
|
|
status?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
} = {}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('tenants/getalltenants', {
|
|
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
|
status: opts.status ?? 'Active',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return dedupeCustomers(toRows(
|
|
await fiestaGet('customers/gettenantcustomers', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 20,
|
|
}),
|
|
));
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// PRODUCTS / STOCK
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** /products/getstockstatement?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= */
|
|
export async function getStockStatement(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
subcategoryid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getstockstatement', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproductscount?tenantid=&categoryid=&subcategoryid=&approve= */
|
|
export async function getProductsCount(opts: {
|
|
tenantid: number;
|
|
categoryid: number;
|
|
subcategoryid?: number;
|
|
}): Promise<Row | null> {
|
|
return firstRow(
|
|
await fiestaGet('products/getproductscount', {
|
|
tenantid: opts.tenantid,
|
|
categoryid: opts.categoryid,
|
|
subcategoryid: opts.subcategoryid,
|
|
approve: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproductstocks?tenantid=&locationid= — live stock levels for an outlet. */
|
|
export async function getProductStocks(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproductstocks', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getlocationproducts?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
|
* geofenced per-outlet inventory. */
|
|
export async function getProductLocations(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
subcategoryid?: number;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getlocationproducts', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /products/getproducts?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= —
|
|
* master catalog listings (global assortment). */
|
|
export async function getMasterCatalog(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
subcategoryid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproducts', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
subcategoryid: opts.subcategoryid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface CreateProductLocationInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
quantity?: number; // User prompt specified quantity
|
|
qty?: number; // Keep for backwards compatibility if needed
|
|
stocktype?: string;
|
|
status?: string;
|
|
price?: number;
|
|
}
|
|
|
|
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. (Expects array payload) */
|
|
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
|
|
const payload = {
|
|
tenantid: input.tenantid,
|
|
locationid: input.locationid,
|
|
productid: input.productid,
|
|
quantity: input.quantity ?? input.qty ?? 0,
|
|
stocktype: input.stocktype || 'in',
|
|
status: input.status || 'Active',
|
|
};
|
|
return fiestaSend<Row>('products/createproductlocation', 'POST', [payload]);
|
|
}
|
|
|
|
export interface DeleteProductLocationInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
}
|
|
|
|
/** DELETE /products/deleteproductlocation — Remove a product from a store catalogue. */
|
|
export async function deleteProductLocation(input: DeleteProductLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/deleteproductlocation', 'DELETE', input);
|
|
}
|
|
|
|
export interface StockRequestInput {
|
|
tenantid: number;
|
|
locationid: number;
|
|
productid: number;
|
|
qty: number;
|
|
status?: string;
|
|
locationname?: string;
|
|
productname?: string;
|
|
productimage?: string;
|
|
tenantname?: string;
|
|
}
|
|
|
|
/** POST /products/createstockrequest — Store user requests stock. */
|
|
export async function createStockRequest(input: StockRequestInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/createstockrequest', 'POST', input);
|
|
}
|
|
|
|
/** /products/getstockrequests?tenantid=&locationid=&status=&pageno=&pagesize= —
|
|
* Fetch pending/approved stock requests. */
|
|
export async function getStockRequests(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
status?: string;
|
|
date?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getstockrequests', {
|
|
tenantid: opts.tenantid,
|
|
locationid: opts.locationid,
|
|
status: opts.status ?? '',
|
|
date: opts.date,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export interface UpdateStockRequestInput {
|
|
requestid?: number;
|
|
tenantid?: number;
|
|
locationid?: number;
|
|
productid?: number;
|
|
status: string;
|
|
}
|
|
|
|
/** PUT /products/updatestockrequest — Admin approves or rejects a stock request. */
|
|
export async function updateStockRequest(input: UpdateStockRequestInput): Promise<Row> {
|
|
return fiestaSend<Row>('products/updatestockrequest', 'PUT', input);
|
|
}
|
|
|
|
/** /products/getproductcategories — global product categories. */
|
|
export async function getProductCategories(): Promise<Row[]> {
|
|
return toRows(await fiestaGet('products/getproductcategories', {}));
|
|
}
|
|
|
|
/** /products/getproductsubcategories?categoryid=&tenantid= — subcategories under a category. */
|
|
export async function getProductSubcategories(opts: {
|
|
categoryid: number;
|
|
tenantid?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('products/getproductsubcategories', {
|
|
categoryid: opts.categoryid,
|
|
tenantid: opts.tenantid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// USERS
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
|
|
export function roleName(roleid: number): string {
|
|
const map: Record<number, string> = {
|
|
[-1]: 'Unassigned',
|
|
0: 'Unassigned',
|
|
1: 'Owner',
|
|
2: 'Manager',
|
|
3: 'Admin',
|
|
4: 'Staff',
|
|
5: 'Rider',
|
|
6: 'Cashier',
|
|
};
|
|
return map[roleid] || `Role ${roleid}`;
|
|
}
|
|
|
|
/** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
|
|
export async function getAllUsers(opts: {
|
|
tenantid: number;
|
|
roleid?: number;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('users/getallusers', {
|
|
tenantid: opts.tenantid,
|
|
roleid: opts.roleid,
|
|
keyword: opts.keyword ?? '',
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 50,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /users/getusers?userid= — a single user profile. */
|
|
export async function getUserById(userid: number): Promise<Row | null> {
|
|
return firstRow(await fiestaGet('users/getusers', { userid }));
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
firstname: string;
|
|
lastname?: string;
|
|
email: string;
|
|
contactno: string;
|
|
/** Optional — merchant_web's create form doesn't collect one. */
|
|
password?: string;
|
|
roleid: number;
|
|
/**
|
|
* app_users.configid — the same column every login call filters on
|
|
* (auth.ts hardcodes configid: 1). Must stay 1 or the account can never
|
|
* be found on login; this is NOT the unrelated Hasura "role config" id.
|
|
*/
|
|
configid?: number;
|
|
/** Business module id (merchant_web sends the logged-in user's; 0 when absent). */
|
|
moduleid?: number;
|
|
dialcode?: string;
|
|
pin?: number;
|
|
address?: string;
|
|
suburb?: string;
|
|
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;
|
|
status?: string;
|
|
}
|
|
|
|
/** POST /users/create — register a new web staff user. */
|
|
export async function createUser(input: CreateUserInput): Promise<Row> {
|
|
return fiestaSend<Row>('users/create', 'POST', {
|
|
authname: input.email,
|
|
firstname: input.firstname,
|
|
lastname: input.lastname ?? '',
|
|
password: input.password ?? '',
|
|
email: input.email,
|
|
dialcode: input.dialcode ?? '+91',
|
|
contactno: input.contactno,
|
|
roleid: input.roleid,
|
|
configid: input.configid ?? 1,
|
|
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,
|
|
status: input.status ?? 'active',
|
|
});
|
|
}
|
|
|
|
export interface UpdateUserInput {
|
|
userid: number;
|
|
firstname?: string;
|
|
lastname?: string;
|
|
email?: string;
|
|
contactno?: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
status?: string;
|
|
roleid?: number | null;
|
|
locationid?: number | null;
|
|
applocationid?: number | null;
|
|
}
|
|
|
|
/** PUT /users/update — update an existing web staff user. */
|
|
export async function updateUser(input: UpdateUserInput): Promise<Row> {
|
|
return fiestaSend<Row>('users/update', 'PUT', input);
|
|
}
|
|
|
|
/**
|
|
* PUT /users/update — set (or reset) a user's login password. Same endpoint as
|
|
* `updateUser`, called with only userid + password so every other column is
|
|
* left untouched (the backend's Updates() skips zero-value fields).
|
|
*/
|
|
export async function setUserPassword(userid: number, password: string): Promise<Row> {
|
|
return fiestaSend<Row>('users/update', 'PUT', { userid, password });
|
|
}
|
|
|
|
export interface CreateTenantLocationPayload {
|
|
locationname: string;
|
|
email?: string;
|
|
contactno?: string;
|
|
address?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postcode?: string;
|
|
applocationid?: number;
|
|
}
|
|
|
|
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;
|
|
applocationid?: number;
|
|
categoryid?: number;
|
|
/** Auto-provisions the tenant's primary (Active) location in the same call. */
|
|
tenantlocations?: CreateTenantLocationPayload;
|
|
}
|
|
|
|
/** POST /tenants/createtenantuser — Onboard a new tenant, primary location, and admin user. */
|
|
export async function createTenantUser(input: CreateTenantInput): Promise<Row> {
|
|
const res = await fetch(`${FIESTA_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;
|
|
moduleid?: number;
|
|
applocationid?: number;
|
|
partnerid?: number;
|
|
roleid?: number;
|
|
}
|
|
|
|
/**
|
|
* POST /tenants/createtenantlocation — Create a new store location under a tenant.
|
|
* The envelope's `details` is the created location row (including the
|
|
* DB-assigned `locationid`), needed right after onboarding to build the
|
|
* store's QR code via `buildStoreQrPayload`.
|
|
*/
|
|
export async function createTenantLocation(input: CreateTenantLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('tenants/createtenantlocation', 'POST', input);
|
|
}
|
|
|
|
export interface UpdateTenantLocationInput {
|
|
locationid: number;
|
|
tenantid?: number;
|
|
locationname?: string;
|
|
contactno?: string;
|
|
email?: string;
|
|
status?: string;
|
|
suburb?: string;
|
|
city?: string;
|
|
}
|
|
|
|
/** PUT /tenants/updatetenantlocation — Update store location details/status. */
|
|
export async function updateTenantLocation(input: UpdateTenantLocationInput): Promise<Row> {
|
|
return fiestaSend<Row>('tenants/updatetenantlocation', 'PUT', input);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// RIDERS / DISPATCH
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** /riders/getriderperiodiclogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= —
|
|
* periodic GPS/status snapshots for a rider across a date range. */
|
|
export async function getRiderPeriodicLogs(opts: {
|
|
userid?: number;
|
|
riderid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
tenantid?: number;
|
|
applocationid?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('riders/getriderperiodiclogs', {
|
|
userid: opts.userid,
|
|
riderid: opts.riderid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getriderlogs?userid=&riderid=&fromdate=&todate=&tenantid=&applocationid= —
|
|
* full telemetry logs (GPS traces, events, etc.) for a rider. */
|
|
export async function getRiderLogs(opts: {
|
|
userid?: number;
|
|
riderid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
tenantid?: number;
|
|
applocationid?: number;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getriderlogs', {
|
|
userid: opts.userid,
|
|
riderid: opts.riderid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
tenantid: opts.tenantid,
|
|
applocationid: opts.applocationid,
|
|
pageno: opts.pageno ?? 1,
|
|
pagesize: opts.pagesize ?? 200,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** /partners/getbatchefficiency?partnerid=&tenantid=&fromdate=&todate= —
|
|
* batch/trip efficiency metrics. */
|
|
export async function getBatchEfficiency(opts: {
|
|
partnerid?: number;
|
|
tenantid: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<Row[]> {
|
|
return toRows(
|
|
await fiestaGet('partners/getbatchefficiency', {
|
|
partnerid: opts.partnerid,
|
|
tenantid: opts.tenantid,
|
|
fromdate: opts.fromdate,
|
|
todate: opts.todate,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** PUT /deliveries/updatedelivery — Manually assign/update a delivery's rider or status. */
|
|
export async function updateDelivery(deliveryid: number, updates: Row): Promise<Row> {
|
|
return fiestaSend<Row>('deliveries/updatedelivery', 'PUT', {
|
|
deliveryid,
|
|
...updates,
|
|
});
|
|
}
|
|
|
|
/** POST /riders/reassigndeliveries — Batch-reassign multiple deliveries to a new rider. */
|
|
export async function reassignDeliveries(opts: {
|
|
userid: number;
|
|
deliveryids: number[];
|
|
}): Promise<Row> {
|
|
return fiestaSend<Row>('riders/reassigndeliveries', 'POST', opts);
|
|
}
|
|
|
|
/** POST /v1/web/tenants/createlocation — Create a new tenant location (outlet). */
|
|
export async function createLocation(opts: {
|
|
tenantid: number;
|
|
locationname: string;
|
|
suburb: string;
|
|
contactno?: string;
|
|
status?: string;
|
|
}): Promise<Row> {
|
|
// Use fiestaSend, note that we include /v1/web explicitly here as the FIESTA_BASE is likely just the root.
|
|
// Wait, let's look at FIESTA_BASE in the file.
|
|
// Actually, I'll just use the full endpoint path.
|
|
return fiestaSend<Row>('v1/web/tenants/createlocation', 'POST', opts);
|
|
}
|
|
|
|
/** PUT /v1/web/tenants/updatelocation — Update an existing tenant location (outlet). */
|
|
export async function updateLocation(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
locationname?: string;
|
|
suburb?: string;
|
|
contactno?: string;
|
|
status?: string;
|
|
}): Promise<Row> {
|
|
return fiestaSend<Row>('v1/web/tenants/updatelocation', 'PUT', opts);
|
|
}
|
|
|
|
/** DELETE /v1/web/tenants/deletelocation — Delete an existing tenant location (outlet). */
|
|
export async function deleteLocation(opts: {
|
|
tenantid: number;
|
|
locationid: number;
|
|
}) {
|
|
const qs = new URLSearchParams({
|
|
tenantid: String(opts.tenantid),
|
|
locationid: String(opts.locationid),
|
|
}).toString();
|
|
return await fiestaSend(`tenants/deletelocation?${qs}`, 'POST');
|
|
}
|
|
|
|
export interface SalesSummaryChartData {
|
|
date: string;
|
|
revenue: number;
|
|
orders: number;
|
|
}
|
|
|
|
export interface SalesSummaryTopLocation {
|
|
locationname: string;
|
|
revenue: number;
|
|
}
|
|
|
|
export interface SalesSummaryResponse {
|
|
totalRevenue: number;
|
|
totalOrders: number;
|
|
averageOrderValue: number;
|
|
chartData: SalesSummaryChartData[];
|
|
topLocations: SalesSummaryTopLocation[];
|
|
}
|
|
|
|
/** GET /v1/web/reports/sales-summary — Fetches aggregated sales and revenue data. */
|
|
export async function getSalesSummary(opts: {
|
|
tenantid: number;
|
|
locationid?: number;
|
|
fromdate: string;
|
|
todate: string;
|
|
}): Promise<SalesSummaryResponse> {
|
|
// Use fiestaGet wrapper which parses standard responses
|
|
const res = await fiestaGet<{ details: SalesSummaryResponse }>('v1/web/reports/sales-summary', opts);
|
|
return res.details;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// GLOBAL CATALOGUE
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** GET /v1/web/catalogue/getbrands — List all brands + product count per brand */
|
|
export async function getGlobalBrands(): Promise<Row[]> {
|
|
return toRows(await fiestaGet('catalogue/getbrands'));
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getcategories — List categories available for that brand */
|
|
export async function getGlobalCategories(opts: { brand: string }): Promise<string[]> {
|
|
const res = await fiestaGet<{ details: string[] }>('catalogue/getcategories', { brand: opts.brand });
|
|
return Array.isArray(res.details) ? res.details : [];
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getproducts — Product list */
|
|
export async function getGlobalProducts(opts: {
|
|
brand: string;
|
|
category?: string;
|
|
keyword?: string;
|
|
pageno?: number;
|
|
pagesize?: number;
|
|
}): Promise<Row[]> {
|
|
return toRows(await fiestaGet('catalogue/getproducts', {
|
|
brand: opts.brand,
|
|
category: opts.category,
|
|
keyword: opts.keyword,
|
|
pageno: opts.pageno,
|
|
pagesize: opts.pagesize,
|
|
}));
|
|
}
|
|
|
|
/** GET /v1/web/catalogue/getproduct — Single product lookup by SKU */
|
|
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
|
|
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
|
|
}
|