udpates on the ui changesand api integration

This commit is contained in:
2026-06-09 11:25:29 +05:30
parent 7dbae96b5f
commit 9f25c5f60a
26 changed files with 4324 additions and 2639 deletions

233
src/services/auth.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Login / authentication client.
*
* The backend login endpoint is the ONE thing that still needs confirming — see
* the CONFIG block below. Everything else (request shape, response parsing,
* role mapping, error handling) is wired and ready. Once the four CONFIG values
* match the real API, the login form verifies credentials and gates entry.
*
* Requests are routed through the existing Vite dev proxy so no secret/CORS
* concern reaches the browser (see vite.config.ts: `/fiesta/*` → fiesta.nearle.app,
* `/hasura/*` → api.workolik.com). If the login route lives on a different host,
* add a proxy entry there and point LOGIN_ENDPOINT at it.
*/
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.
* 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';
/** Request body field names the endpoint expects for the credentials. */
const REQUEST_FIELDS = {
email: 'authname',
password: 'password',
} as const;
/** Extra fields the applogin endpoint expects alongside the credentials. */
const EXTRA_FIELDS = {
configid: 1,
userfcmtoken: null,
} as const;
/**
* Field names read from the user record (login response `details`, else the
* tenant user list). Verified against the live `users/getallusers` response.
*/
const RESPONSE_FIELDS = {
roleid: 'roleid',
firstname: 'firstname',
fullname: 'fullname',
email: 'email',
contactno: 'contactno',
userid: 'userid',
// 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
// user record — the outlet is resolved from the tenant locations list).
applocationid: 'applocationid',
applocation: 'applocation',
locationid: 'locationid',
locationname: 'locationname',
} as const;
/**
* roleids that land on the ADMIN dashboard; everyone else lands on the user page.
* From fiestaApi.roleName(): 1 = Owner, 2 = Manager, 3 = Admin, 4 = Staff,
* 5 = Rider, 6 = Cashier.
*/
const ADMIN_ROLE_IDS = new Set<number>([1, 3]);
// ──────────────────────────────────────────────────────────────────────────────
export type LoginRole = 'admin' | 'user';
export interface AuthUser {
role: LoginRole;
name: string;
email: string;
userid?: number;
roleid?: number;
/** Phone number on the user record. */
contactno?: string;
/** The app-location this user is allocated to. */
applocationid?: number;
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
applocation?: string;
/** Outlet/location id, when the record carries it directly (often 0). */
locationid?: number;
/** Outlet display name, when the record carries it directly. */
locationname?: string;
}
/** Map a numeric roleid to the workspace the user is allowed into. */
export function roleFromRoleId(roleid: number): LoginRole {
return ADMIN_ROLE_IDS.has(roleid) ? 'admin' : 'user';
}
/** Build a friendly display name from the response, falling back to the email. */
function displayName(row: Record<string, unknown>, email: string): string {
const full = str(row[RESPONSE_FIELDS.fullname]).trim();
if (full) return full;
const first = str(row[RESPONSE_FIELDS.firstname]).trim();
if (first) return first;
// Fallback: derive from the email's local part.
return (
email
.split('@')[0]
.split(/[._-]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ') || 'Account'
);
}
/** The verified-login response: the user record (if any) + the resolved email. */
export interface LoginResult {
/** User record from the login response `details`, or null if it carried none. */
row: Row | null;
/** Whether `row` already includes the role (so no tenant-list lookup is needed). */
hasRole: boolean;
email: string;
}
/**
* POST the credentials to the Fiesta web-login endpoint. Resolves with the raw
* user record on success; throws an Error with a user-facing message on invalid
* credentials or any failure. Role resolution is left to the caller so the
* (optional) tenant-user lookup can go through React Query — see `useLogin`.
*/
export async function loginRequest(email: string, password: string): Promise<LoginResult> {
const trimmedEmail = email.trim();
let res: Response;
try {
res = await fetch(LOGIN_ENDPOINT, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
[REQUEST_FIELDS.email]: trimmedEmail,
[REQUEST_FIELDS.password]: password,
...EXTRA_FIELDS,
}),
});
} catch {
throw new Error('Unable to reach the login service. Check your connection and try again.');
}
// Parse the JSON body (may be absent on some error responses).
const json = (await res.json().catch(() => null)) as
| { code?: number; status?: boolean; message?: string; details?: unknown }
| null;
// Failure: HTTP error, or the Fiesta `status: false` envelope (e.g. wrong
// email/password → { code: 409, message: "Invalid Email", status: false }).
if (!res.ok || (json && json.status === false)) {
throw new Error(json?.message?.trim() || 'Incorrect email or password.');
}
const row = firstRow<Row>(json);
const resolvedEmail = (row && str(row[RESPONSE_FIELDS.email]).trim()) || trimmedEmail;
return { row, hasRole: Boolean(row && row[RESPONSE_FIELDS.roleid] != null), email: resolvedEmail };
}
/**
* Checks if the email/authname exists and is registered by sending email and configid: 1.
* Returns true if the email exists, false if it is invalid.
*/
export async function checkEmailRequest(email: string): Promise<boolean> {
const trimmedEmail = email.trim();
let res: Response;
try {
res = await fetch(LOGIN_ENDPOINT, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
[REQUEST_FIELDS.email]: trimmedEmail,
...EXTRA_FIELDS,
}),
});
} catch {
throw new Error('Unable to reach the login service. Check your connection and try again.');
}
const json = (await res.json().catch(() => null)) as
| { code?: number; status?: boolean; message?: string }
| null;
// A 409 Invalid Email code means the email does not exist.
if (json && json.status === false && (json.code === 409 || json.message?.trim().toLowerCase() === 'invalid email')) {
return false;
}
// Any other result (such as 403 Unauthorized email) means the email is registered.
return true;
}
/** Find a user in the tenant user list by email or authname (case-insensitive). */
export function matchTenantUser(users: Row[], email: string): Row | null {
const target = email.toLowerCase();
return (
users.find(
(u) =>
str(u[RESPONSE_FIELDS.email]).toLowerCase() === target ||
str(u.authname).toLowerCase() === target,
) ?? null
);
}
/** Assemble the final AuthUser (role + identity) from a resolved user record. */
export function buildAuthUser(row: Row | null, email: string): AuthUser {
const roleid = row ? num(row[RESPONSE_FIELDS.roleid]) : 0;
const applocation = row ? str(row[RESPONSE_FIELDS.applocation]).trim() : '';
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
return {
role: roleFromRoleId(roleid),
name: displayName(row ?? {}, email),
email,
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
roleid,
contactno: contactno || undefined,
applocationid:
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
applocation: applocation || undefined,
locationid: row && row[RESPONSE_FIELDS.locationid] != null ? num(row[RESPONSE_FIELDS.locationid]) : undefined,
locationname: locationname || undefined,
};
}

View File

@@ -183,6 +183,28 @@ 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 }));
}
/** /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
// ════════════════════════════════════════════════════════════════════════════
@@ -248,6 +270,48 @@ 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,
}),
);
}
// ════════════════════════════════════════════════════════════════════════════
// PARTNERS / RIDERS
// ════════════════════════════════════════════════════════════════════════════
@@ -356,6 +420,79 @@ export async function getProductsCount(opts: {
);
}
/** /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/getproductlocations?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/getproductlocations', {
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,
}),
);
}
/** /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
// ════════════════════════════════════════════════════════════════════════════

View File

@@ -14,6 +14,7 @@
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import type { Row } from './fiestaApi';
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
import {
FIESTA_TENANT_ID,
FIESTA_APPLOCATION_ID,
@@ -25,6 +26,10 @@ import {
getDeliverySummary,
getDeliveries,
getDeliveryInsight,
getDeliveryReport,
getFleetSummary,
getOrderDetails,
getCustomerOrders,
getRiders,
getRiderShifts,
getTenantLocations,
@@ -32,6 +37,11 @@ import {
getTenantCustomers,
getStockStatement,
getProductsCount,
getProductStocks,
getProductLocations,
getMasterCatalog,
getProductCategories,
getProductSubcategories,
getAllUsers,
getUserById,
createUser,
@@ -55,6 +65,15 @@ export const fiestaKeys = {
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
productsCount: (params: Record<string, unknown>) => ['fiesta', 'productsCount', params] as const,
productStocks: (params: Record<string, unknown>) => ['fiesta', 'productStocks', params] as const,
productLocations: (params: Record<string, unknown>) => ['fiesta', 'productLocations', params] as const,
masterCatalog: (params: Record<string, unknown>) => ['fiesta', 'masterCatalog', params] as const,
productCategories: () => ['fiesta', 'productCategories'] as const,
productSubcategories: (params: Record<string, unknown>) => ['fiesta', 'productSubcategories', params] as const,
orderDetails: (orderheaderid: number | string) => ['fiesta', 'orderDetails', orderheaderid] as const,
customerOrders: (params: Record<string, unknown>) => ['fiesta', 'customerOrders', params] as const,
deliveryReport: (params: Record<string, unknown>) => ['fiesta', 'deliveryReport', params] as const,
fleetSummary: (params: Record<string, unknown>) => ['fiesta', 'fleetSummary', params] as const,
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
user: (userid: number) => ['fiesta', 'user', userid] as const,
};
@@ -238,6 +257,117 @@ export function useFiestaStoresStock(
}));
}
// ── Order details / customer history ───────────────────────────────────────────
export function useFiestaOrderDetails(orderheaderid: number | string | null | undefined) {
return useQuery({
queryKey: fiestaKeys.orderDetails(orderheaderid ?? ''),
queryFn: () => getOrderDetails(orderheaderid as number | string),
enabled: Boolean(orderheaderid),
});
}
export function useFiestaCustomerOrders(opts: {
customerid: number | string | null | undefined;
status?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.customerOrders(opts as Record<string, unknown>),
queryFn: () =>
getCustomerOrders({
customerid: opts.customerid as number | string,
status: opts.status,
pageno: opts.pageno,
pagesize: opts.pagesize,
}),
enabled: Boolean(opts.customerid),
});
}
// ── Deliveries report / fleet ───────────────────────────────────────────────────
export function useFiestaDeliveryReport(opts: {
tenantid: number;
applocationid?: number;
partnerid?: number;
userid?: number;
fromdate: string;
todate: string;
}) {
return useQuery({
queryKey: fiestaKeys.deliveryReport(opts),
queryFn: () => getDeliveryReport(opts),
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
export function useFiestaFleetSummary(opts: {
tenantid: number;
applocationid?: number;
partnerid?: number;
fromdate: string;
todate: string;
}) {
return useQuery({
queryKey: fiestaKeys.fleetSummary(opts),
queryFn: () => getFleetSummary(opts),
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
});
}
// ── Products: live stocks / catalog / categories ────────────────────────────────
export function useFiestaProductStocks(opts: { tenantid: number; locationid: number }) {
return useQuery({
queryKey: fiestaKeys.productStocks(opts),
queryFn: () => getProductStocks(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
export function useFiestaProductLocations(opts: {
tenantid: number;
locationid: number;
subcategoryid?: number;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.productLocations(opts),
queryFn: () => getProductLocations(opts),
enabled: Boolean(opts.tenantid && opts.locationid),
});
}
export function useFiestaMasterCatalog(opts: {
tenantid: number;
locationid?: number;
subcategoryid?: number;
keyword?: string;
pageno?: number;
pagesize?: number;
}) {
return useQuery({
queryKey: fiestaKeys.masterCatalog(opts),
queryFn: () => getMasterCatalog(opts),
enabled: Boolean(opts.tenantid),
});
}
export function useFiestaProductCategories() {
return useQuery({
queryKey: fiestaKeys.productCategories(),
queryFn: () => getProductCategories(),
});
}
export function useFiestaProductSubcategories(opts: { categoryid: number; tenantid?: number }) {
return useQuery({
queryKey: fiestaKeys.productSubcategories(opts),
queryFn: () => getProductSubcategories(opts),
enabled: Boolean(opts.categoryid),
});
}
// ── Users ─────────────────────────────────────────────────────────────────────
export function useFiestaUsers(opts: {
tenantid: number;
@@ -279,4 +409,38 @@ export function useFiestaUpdateUser() {
});
}
// ── Auth ──────────────────────────────────────────────────────────────────────
/**
* Verify login credentials against the Fiesta web-login endpoint. A mutation
* (not a query) since it's a POST with side effects; the form drives it via
* `mutate`/`mutateAsync` and reads `isPending`/`error` for loading + error UI.
*
* Both network calls go through React Query: the login POST is the mutation,
* and the role-resolution fallback (when the login response omits the role) is
* fetched via the query client — so it shares the Users-panel cache.
*/
export function useLogin() {
const qc = useQueryClient();
return useMutation<AuthUser, Error, { email: string; password: string }>({
mutationFn: async ({ email, password }) => {
const result = await loginRequest(email, password);
let row = result.row;
// The login response didn't carry a role — resolve it from the tenant user
// list through the query cache (deduped with useFiestaUsers).
if (!result.hasRole) {
const params = { tenantid: FIESTA_TENANT_ID, keyword: result.email, pagesize: 50 };
const users = await qc.fetchQuery({
queryKey: fiestaKeys.users(params),
queryFn: () => getAllUsers(params),
});
const match = matchTenantUser(users, result.email);
if (match) row = { ...match, ...(row ?? {}) };
}
return buildAuthUser(row, result.email);
},
});
}
export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };