pos implemented

This commit is contained in:
2026-08-10 18:14:52 +05:30
parent 14e4529613
commit 31d5535b72
14 changed files with 1373 additions and 301 deletions

View File

@@ -1058,20 +1058,45 @@ export async function getProductSubcategories(opts: {
// ════════════════════════════════════════════════════════════════════════════
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
/**
* Role names exactly as `app_roles` holds them.
*
* These were wrong for five of six ids until 2026-08-07 — the old map said
* 1 Owner / 2 Manager / 4 Staff / 5 Rider / 6 Cashier, none of which the
* database agrees with. Anything deriving behaviour from a *name* was
* therefore deriving it from fiction, which is how `isRiderRole` came to key
* off roleid 5 ("Admin").
*
* Note 3 and 5 are both "Admin", and 4 and 6 are both "Manager" — they are the
* same role under different configids (15 and 14). That is the schema's shape,
* not a transcription error, so it is preserved here rather than tidied.
*/
const ROLE_NAMES: Record<number, string> = {
[-1]: 'Unassigned',
0: 'Unassigned',
1: 'Super admin',
2: 'Operations',
3: 'Admin',
4: 'Manager',
5: 'Admin',
6: 'Manager',
// Till-only. These accounts have no Nearle Daily login at all — the backend
// excludes them from every web login lookup — so they are never the role of
// a signed-in console user. Listed because the POS staff screens render them.
7: 'Supervisor',
8: 'Cashier',
};
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}`;
return ROLE_NAMES[roleid] || `Role ${roleid}`;
}
/** Till roles. Created through `tenants/createposuser`, never `users/create`. */
export const POS_ROLE_SUPERVISOR = 7;
export const POS_ROLE_CASHIER = 8;
export const isPosRole = (roleid: number): boolean =>
roleid === POS_ROLE_SUPERVISOR || roleid === POS_ROLE_CASHIER;
/** /users/getallusers?roleid=&tenantid=&pageno=&pagesize=&keyword= — staff/users under a tenant. */
export async function getAllUsers(opts: {
tenantid: number;
@@ -1650,3 +1675,189 @@ export async function getGlobalProducts(opts: {
export async function getGlobalProduct(opts: { brand: string; sku: string }): Promise<Row | null> {
return firstRow(await fiestaGet('catalogue/getproduct', { brand: opts.brand, sku: opts.sku }));
}
// ════════════════════════════════════════════════════════════════════════════
// POS staff — supervisors and cashiers
// ════════════════════════════════════════════════════════════════════════════
//
// A separate surface from `users/create` on purpose. A till account has no
// Nearle Daily login (the backend excludes roleid 7 and 8 from every web login
// lookup), and its username, password and PIN are all minted server-side. Sent
// through `users/create` it would land as a row with none of those and be
// unable to open a till.
//
// Consequently these people never appear in `getallusers` either — the two
// lists are disjoint by design, not by omission.
/** One person who signs in at a till. */
export interface PosStaffUser {
user_id: number;
full_name: string;
first_name?: string;
last_name?: string;
authname?: string;
contactno?: string;
role_id: number;
role: string;
pin?: string;
has_password: boolean;
/** Present ONLY in the answer to a create or a reset. Never in a listing. */
password?: string;
location_id: number;
status: string;
}
export interface PosRoleOption {
role_id: number;
/** The wire value `createposuser` expects — "supervisor" | "cashier". */
role: string;
label: string;
description: string;
}
/** GET /tenants/posroles — the roles a till account may hold. Asked for rather
* than hardcoded, so adding a till role later needs no frontend release. */
export async function getPosRoles(): Promise<PosRoleOption[]> {
return toRows<PosRoleOption>(await fiestaGet('tenants/posroles'));
}
/**
* GET /tenants/getposusers — till accounts at one outlet. Never returns a password.
*
* Note the envelope: `details` is an OBJECT — `{location_id, users: [...]}` —
* not the bare array most Fiesta reads return. Verified against the live
* response. Passing it through `toRows` yields one wrapper object rather than
* the people, so the array is taken explicitly.
*/
export async function getPosStaff(opts: {
tenantid: number;
locationid: number;
}): Promise<PosStaffUser[]> {
const json = await fiestaGet<{ details?: { users?: PosStaffUser[] } | PosStaffUser[] }>(
'tenants/getposusers',
{
tenantid: opts.tenantid,
locationid: opts.locationid,
// Always asked for. The endpoint hides deactivated people by default,
// which reads sensibly for a till but not for the screen that manages
// them: a person deactivated by mistake would vanish with no way back,
// and their PIN would silently stay reserved. The panel shows them
// greyed with a Reactivate action instead.
include_inactive: 'true',
},
);
const d = json?.details;
if (Array.isArray(d)) return d;
return d?.users ?? [];
}
export interface CreatePosStaffInput {
tenantid: number;
locationid: number;
full_name: string;
/** "supervisor" | "cashier" — from getPosRoles, not a hardcoded literal. */
role: string;
pin: string;
/** Optional. Omitted, the server generates one and walks past collisions. */
authname?: string;
contactno?: string;
}
/**
* POST /tenants/createposuser
*
* The response is the ONLY time the password exists — it is not stored
* anywhere the console can read back, and the listing endpoint omits it. Show
* it once and mean it.
*
* `authname` may come back different from anything sent or expected: a shop
* that already has `supervisor.1179@` gets `supervisor2.1179@` rather than an
* error, so the returned value is the one to display.
*/
export async function createPosStaff(input: CreatePosStaffInput): Promise<PosStaffUser> {
const res = await fiestaSend<{ details?: PosStaffUser }>(
'tenants/createposuser',
'POST',
input,
);
const row = firstRow<PosStaffUser>(res);
if (!row) throw new Error('The server accepted the request but returned no account.');
return row;
}
/**
* PUT /tenants/updateposuser
*
* Every field is optional and only non-empty ones are written, so this doubles
* as the reactivate path (`status: 'Active'`) and the password reset.
*
* Unlike create, the password here is **caller-supplied** — the server writes
* what it is given rather than minting one. A console offering a reset has to
* generate it (see `generatePosPassword`) and show it once, because there is
* still nowhere to read it back from.
*
* PIN uniqueness is enforced server-side under an advisory lock, so a clash
* comes back as an error rather than two people sharing a PIN.
*/
export async function updatePosStaff(input: {
tenantid: number;
locationid: number;
user_id: number;
full_name?: string;
role?: string;
pin?: string;
authname?: string;
contactno?: string;
password?: string;
status?: string;
}): Promise<Row> {
return fiestaSend<Row>('tenants/updateposuser', 'PUT', input);
}
/**
* A replacement password for a till account.
*
* Generated in the browser from `crypto.getRandomValues` — the update endpoint
* writes whatever it is handed, so the strength of a reset password is entirely
* this function's responsibility. Fourteen characters from a 62-symbol alphabet,
* matching what the server mints on create.
*
* Ambiguous glyphs are kept rather than stripped: these are copied, not read
* aloud, and shrinking the alphabet costs entropy for no real gain.
*/
export function generatePosPassword(length = 14): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = new Uint32Array(length);
crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
return out;
}
/**
* DELETE /tenants/deleteposuser
*
* Deactivates rather than removes — bills carry the cashier's name, and a
* deleted row would orphan every one of them. The till refuses a deactivated
* account immediately.
*/
export async function deactivatePosStaff(opts: {
tenantid: number;
locationid: number;
userid: number;
}): Promise<Row> {
const qs = new URLSearchParams({
tenantid: String(opts.tenantid),
locationid: String(opts.locationid),
userid: String(opts.userid),
});
const res = await fetch(`${FIESTA_BASE}/tenants/deleteposuser?${qs}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
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 || `Could not deactivate the account (${res.status}).`);
}
return json as Row;
}