Initial commit

This commit is contained in:
2026-08-24 20:35:18 +05:30
commit 1dc582ba07
85 changed files with 21081 additions and 0 deletions

137
src/demo/DemoPanel.tsx Normal file
View File

@@ -0,0 +1,137 @@
/**
* Demo sign-in — development only.
*
* Reached solely through a lazy import guarded by `import.meta.env.DEV`, so
* this file and `./accounts` are absent from a production build.
*/
import { ArrowRight } from 'lucide-react';
import { ROLE_LABEL, type ConsoleRole, type SessionUser } from '@/auth/roles';
import { DEMO_ACCOUNTS } from './accounts';
/**
* Three one-click sessions, one per role.
*
* Present only when `import.meta.env.DEV` is true, so a production build drops
* this component and the fixtures behind it. It is deliberately styled as a
* development tool rather than as part of the form — dashed border, a "DEV
* ONLY" tag — because a sign-in shortcut that looks like a real sign-in option
* is exactly the thing that ends up in a screenshot for a customer.
*/
export function DemoPanel({
onDemo,
}: {
onDemo: (role: ConsoleRole, session: SessionUser) => void;
}) {
const roles: ConsoleRole[] = ['nearle-admin', 'store-admin', 'store-manager'];
return (
<div
style={{
border: '1px dashed var(--color-border-emphasized, #cbd2db)',
borderRadius: 14,
padding: 14,
background: 'color-mix(in oklab, var(--color-surface-sunken) 55%, transparent)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span
style={{
background: 'var(--color-ink-1)',
color: '#fff',
borderRadius: 999,
padding: '2px 7px',
fontSize: 9.5,
fontWeight: 700,
letterSpacing: '0.08em',
}}
>
DEV ONLY
</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-2)' }}>
Sign in without the backend
</span>
</div>
<p style={{ margin: '0 0 10px', fontSize: 11.5, lineHeight: 1.55, color: 'var(--color-ink-4)' }}>
Seeds a session and serves fixtures instead of calling Fiesta. Stripped from production
builds.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{roles.map((role) => {
const account = DEMO_ACCOUNTS[role];
if (!account) return null;
return (
<button
key={role}
type="button"
onClick={() => onDemo(role, account.session)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
textAlign: 'left',
padding: '9px 11px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
cursor: 'pointer',
transition: 'border-color .2s, background .2s',
}}
>
<span
style={{
width: 26,
height: 26,
borderRadius: 8,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 700,
flex: 'none',
}}
>
{account.session.name
.split(' ')
.map((part) => part[0])
.slice(0, 2)
.join('')}
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span
style={{
display: 'block',
fontSize: 12.5,
fontWeight: 600,
color: 'var(--color-ink-1)',
}}
>
{ROLE_LABEL[role]}
<span style={{ fontWeight: 400, color: 'var(--color-ink-4)' }}>
{' '}
· {account.standsFor}
</span>
</span>
<span
style={{
display: 'block',
fontSize: 11,
lineHeight: 1.45,
color: 'var(--color-ink-3)',
}}
>
{account.blurb}
</span>
</span>
<ArrowRight size={14} style={{ color: 'var(--color-ink-4)', flex: 'none' }} />
</button>
);
})}
</div>
</div>
);
}

69
src/demo/accounts.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* The three demo accounts.
*
* Split from `src/demo/index.ts` so they can be reached only through the lazily
* loaded demo panel — a static import would put these session objects in the
* production bundle, and a build that ships fake accounts is a build that
* eventually ships one to a customer.
*
* DEV ONLY.
*/
import type { SessionUser } from '@/auth/roles';
export interface DemoAccount {
session: SessionUser;
/** What this role is for, in one line, shown on the button. */
blurb: string;
/** The real login this stands in for, so it is obvious what it maps to. */
standsFor: string;
}
/**
* One per role, matching how `resolveRole` actually decides:
* `issuperadmin` first, then roleid 1 or 3, then everything else.
*/
export const DEMO_ACCOUNTS: Record<string, DemoAccount> = {
'nearle-admin': {
session: {
userid: 9001,
role: 'nearle-admin',
name: 'Suriya Kumar',
email: 'suriya@superadmin.com',
roleid: 0,
tenantid: 0,
locationid: 0,
issuperadmin: true,
},
blurb: 'Onboard tenants and branches, browse the global catalogue, see every store.',
standsFor: 'issuperadmin = true',
},
'store-admin': {
session: {
userid: 9002,
role: 'store-admin',
name: 'Rmart Operations',
email: 'rmart@gmail.com',
roleid: 1,
tenantid: 1135,
locationid: 1,
issuperadmin: false,
},
blurb: 'One merchant group — its branches, catalogue, reports and tills.',
standsFor: 'roleid 1',
},
'store-manager': {
session: {
userid: 9003,
role: 'store-manager',
name: 'R Mart User',
email: 'rmartuser@gmail.com',
roleid: 5,
tenantid: 1135,
locationid: 1,
issuperadmin: false,
},
blurb: 'A single store — its orders, stock, customers and counter sales.',
standsFor: 'any other roleid',
},
};

961
src/demo/fixtures.ts Normal file
View File

@@ -0,0 +1,961 @@
/**
* Demo fixtures.
*
* Shapes and values are copied from the live tenant so the screens read as the
* real thing rather than as lorem ipsum: R mart in RS Puram, the Aachi/Amul/
* Cadbury catalogue, the 97.6% cancel rate. Anyone who knows the data will
* recognise it, which is the point — a demo that looks nothing like production
* teaches you nothing about production.
*
* DEV ONLY. See `src/demo/index.ts` for how this is gated and stripped.
*/
import type {
CatalogueBrand,
CatalogueProduct,
CatalogueRef,
LocationOrderSummary,
OrderSummary,
Product,
ProductCategory,
TenantInfo,
TenantLocation,
} from '@/api/types';
export const DEMO_TENANTS: TenantInfo[] = [
{
tenantid: 1135,
locationid: 1,
tenantname: 'R mart',
locationname: 'RS Puram',
companyname: 'R Mart Retail Pvt. Ltd.',
primaryemail: 'rmart@gmail.com',
primarycontact: '9879879876',
city: 'Coimbatore',
state: 'Tamil Nadu',
suburb: 'RS Puram',
postcode: '641002',
status: 'Active',
},
{
tenantid: 1136,
locationid: 4,
tenantname: 'Kaveri Groceries',
locationname: 'Peelamedu',
companyname: 'Kaveri Retail Pvt. Ltd.',
primaryemail: 'admin@kaveri.com',
primarycontact: '9876543210',
city: 'Coimbatore',
state: 'Tamil Nadu',
suburb: 'Peelamedu',
postcode: '641004',
status: 'Active',
},
{
tenantid: 1136,
locationid: 5,
tenantname: 'Kaveri Groceries',
locationname: 'Saibaba Colony',
companyname: 'Kaveri Retail Pvt. Ltd.',
primaryemail: 'admin@kaveri.com',
primarycontact: '9876543210',
city: 'Coimbatore',
state: 'Tamil Nadu',
status: 'Active',
},
{
tenantid: 1137,
locationid: 8,
tenantname: 'Anbu Stores',
locationname: 'Gandhipuram',
companyname: 'Anbu Traders',
primaryemail: 'andiraj@gmail.com',
primarycontact: '7397177923',
city: 'Coimbatore',
state: 'Tamil Nadu',
status: 'InActive',
},
];
export const DEMO_LOCATIONS: Record<number, TenantLocation[]> = {
1135: [
{
locationid: 1,
tenantid: 1135,
locationname: 'R mart — RS Puram',
email: 'rspuram@rmart.com',
contactno: '9879879876',
address: '240, DB Road',
suburb: 'RS Puram',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641002',
opentime: '08:00',
closetime: '22:00',
deliveryradius: 5000,
deliverymins: 30,
status: 'Active',
},
{
locationid: 2,
tenantid: 1135,
locationname: 'R mart — Peelamedu',
email: 'peelamedu@rmart.com',
contactno: '9879879877',
address: '18, Avinashi Road',
suburb: 'Peelamedu',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641004',
opentime: '07:00',
closetime: '23:00',
deliveryradius: 6000,
deliverymins: 25,
status: 'Active',
},
{
locationid: 3,
tenantid: 1135,
locationname: 'R mart — Saravanampatti',
email: 'saravanampatti@rmart.com',
contactno: '9879879878',
address: '5, Sathy Main Road',
suburb: 'Saravanampatti',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641035',
opentime: '08:00',
closetime: '22:00',
deliveryradius: 4500,
deliverymins: 35,
status: 'Active',
},
],
1136: [
{
locationid: 4,
tenantid: 1136,
locationname: 'Kaveri — Peelamedu',
address: '12, Avinashi Road',
suburb: 'Peelamedu',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641004',
opentime: '07:00',
closetime: '23:00',
deliveryradius: 7000,
deliverymins: 25,
status: 'Active',
},
{
locationid: 5,
tenantid: 1136,
locationname: 'Kaveri — Saibaba Colony',
address: '88, Mettupalayam Road',
suburb: 'Saibaba Colony',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641011',
opentime: '08:00',
closetime: '22:00',
deliveryradius: 4000,
deliverymins: 35,
status: 'Active',
},
],
1137: [
{
locationid: 8,
tenantid: 1137,
locationname: 'Anbu — Gandhipuram',
address: '5, Cross Cut Road',
city: 'Coimbatore',
state: 'Tamil Nadu',
postcode: '641012',
opentime: '09:00',
closetime: '21:00',
deliveryradius: 3000,
deliverymins: 40,
status: 'InActive',
},
],
};
/** Per-branch order totals — the shape `/orders/getlocationsummary` returns. */
export const DEMO_LOCATION_SUMMARY: Record<number, LocationOrderSummary[]> = {
1135: [
{ locationid: 1, locationname: 'R mart — RS Puram', totalorders: 84, delivered: 1, cancelled: 82, revenue: 1350 },
{ locationid: 2, locationname: 'R mart — Peelamedu', totalorders: 268, delivered: 241, cancelled: 19, revenue: 58400 },
{ locationid: 3, locationname: 'R mart — Saravanampatti', totalorders: 142, delivered: 126, cancelled: 12, revenue: 27950 },
],
1136: [
{ locationid: 4, locationname: 'Kaveri — Peelamedu', totalorders: 412, delivered: 388, cancelled: 11, revenue: 84250 },
{ locationid: 5, locationname: 'Kaveri — Saibaba Colony', totalorders: 196, delivered: 181, cancelled: 6, revenue: 39800 },
],
1137: [{ locationid: 8, locationname: 'Anbu — Gandhipuram', totalorders: 0, delivered: 0, cancelled: 0, revenue: 0 }],
};
export const DEMO_ORDER_SUMMARY: Record<number, OrderSummary> = {
1135: { totalorders: 84, delivered: 1, pending: 0, cancelled: 82, revenue: 1350 },
1136: { totalorders: 608, delivered: 569, pending: 22, cancelled: 17, revenue: 124050 },
1137: { totalorders: 0, delivered: 0, pending: 0, cancelled: 0, revenue: 0 },
};
export const DEMO_BRANDS: CatalogueBrand[] = [
{ brand: 'aachi', product_count: 176 },
{ brand: 'amul', product_count: 122 },
{ brand: 'anil', product_count: 50 },
{ brand: 'cadbury', product_count: 110 },
{ brand: 'cavinkare', product_count: 45 },
{ brand: 'nestle', product_count: 123 },
{ brand: 'pepsico', product_count: 88 },
];
function catalogueProduct(
id: number,
brand: string,
name: string,
size: string,
range: string,
category: string,
): CatalogueProduct {
return {
id,
brand,
product_name: name,
size,
price_range: range,
category,
product_sku: `${brand.toUpperCase()}-${id}`,
};
}
export const DEMO_CATALOGUE: CatalogueProduct[] = [
catalogueProduct(43, 'aachi', 'Aachi Appalams 100g', '100g', '₹33-37', 'Snacks'),
catalogueProduct(89, 'aachi', 'Aachi Chilli Powder 100gm', '100gm', '₹31-39', 'Masala'),
catalogueProduct(7, 'aachi', 'Aachi Chicken Masala 100g', '100g', '₹23-27', 'Masala'),
catalogueProduct(20, 'aachi', 'Aachi Garam Masala 100g', '100g', '₹22-28', 'Masala'),
catalogueProduct(102, 'aachi', 'Aachi Sambar Powder 50g', '50g', '₹14-16', 'Masala'),
catalogueProduct(51, 'amul', 'Amul Milk 1L', '1L', '₹68-92', 'Dairy'),
catalogueProduct(3, 'amul', 'Amul Butter 500ml', '500ml', '₹80-100', 'Dairy'),
catalogueProduct(37, 'amul', 'Amul Dahi 200 g', '200 g', '₹28-32', 'Dairy'),
catalogueProduct(20, 'amul', 'Amul Ghee 1L', '1L', '₹158-192', 'Dairy'),
catalogueProduct(77, 'amul', 'Amul 150 g', '150 g', '₹37-43', 'Dairy'),
catalogueProduct(9, 'cadbury', 'Cadbury Bournvita 500g', '500g', '₹210-230', 'Beverages'),
catalogueProduct(12, 'cadbury', 'Cadbury Dairy Milk 100g', '100g', '₹77-93', 'Confectionery'),
catalogueProduct(37, 'cadbury', 'Cadbury Oreo 100g', '100g', '₹10-14', 'Biscuits'),
catalogueProduct(15, 'cadbury', 'Cadbury Gems 25g', '25g', '₹22-28', 'Confectionery'),
catalogueProduct(10, 'anil', 'Anil Wheat Flour 1kg', '1kg', '₹251-299', 'Staples'),
catalogueProduct(34, 'anil', 'Anil Idli Dosa Mix 500g', '500g', '₹98-122', 'Staples'),
catalogueProduct(1, 'cavinkare', 'Cavinkare Frooti 200g', '200g', '₹42-48', 'Beverages'),
catalogueProduct(30, 'cavinkare', 'Cavinkare Chik Shampoo 500ml', '500ml', '₹377-463', 'Personal care'),
];
/** `(brand, catalogueid)` pairs the demo tenant has already imported. */
export const DEMO_IMPORTED: Record<number, CatalogueRef[]> = {
1135: [
{ brand: 'cadbury', catalogueid: 9 },
{ brand: 'amul', catalogueid: 77 },
{ brand: 'amul', catalogueid: 51 },
],
1136: [{ brand: 'aachi', catalogueid: 89 }],
1137: [],
};
export const DEMO_CATEGORIES: ProductCategory[] = [
{ categoryid: 1, categoryname: 'Grocery & Staples' },
{ categoryid: 2, categoryname: 'Dairy & Chilled' },
{ categoryid: 3, categoryname: 'Snacks & Beverages' },
{ categoryid: 4, categoryname: 'Personal Care' },
{ categoryid: 5, categoryname: 'Household' },
];
export const DEMO_STORE_PRODUCTS: Product[] = [
{
productid: 7081,
productname: 'Cadbury Bournvita 500g',
productsku: 'B06XSB6RB2',
productunit: '500g',
retailprice: 100,
price: 100,
productstock: 0,
productstatus: 'Active',
categoryname: 'Snacks & Beverages',
},
{
productid: 7077,
productname: 'Hot Heads 30g',
productsku: 'NESTLE-HOT-30-001',
productunit: '30g',
retailprice: 50,
price: 50,
productstock: 47,
productstatus: 'Active',
categoryname: 'Snacks & Beverages',
},
{
productid: 7076,
productname: 'Cheerios Breakfast Cereal 100g',
productsku: 'B08X68PTVG',
productunit: '100g',
retailprice: 120,
price: 120,
productstock: 17,
productstatus: 'Active',
categoryname: 'Grocery & Staples',
},
];
/* ────────────────────────────────────────────────────────────────────────────
Store Admin — POS, stock and the approval queue
The three R Mart branches are deliberately in three different states, because
a demo where everything is green teaches nothing about the screen:
- RS Puram one till, synced, trading normally
- Peelamedu two tills, one of them syncing and one stranded for an hour
- Saravanampatti till offline entirely, with takings queued behind it
──────────────────────────────────────────────────────────────────────────── */
import type { PosSalesSummary, PosTerminalHealth, StockRequest, StockStatementRow } from '@/api/types';
/** Minutes ago, as the ISO string a till would have reported. */
const ago = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
export const DEMO_POS_HEALTH: Record<number, PosTerminalHealth[]> = {
1: [
{
terminal_id: 'RSP-01',
location_id: '1',
store_name: 'R mart — RS Puram',
app_version: '2.4.1',
status: 'online',
pending_bills: '0',
pending_registrations: '0',
today_bills: '46',
today_amount: '18240',
last_bill_at: ago(3),
reported_at: ago(0),
received_at: ago(0),
printer_reachable: 'true',
storage_free_mb: '2400',
battery_level: '86',
battery_charging: 'true',
drawer_status: 'closed',
},
{
// The regression case, made visible: still claiming "online", but nothing
// has arrived in six minutes. The old derivation badged this "Synced".
terminal_id: 'RSP-02',
location_id: '1',
store_name: 'R mart — RS Puram',
app_version: '2.4.1',
status: 'online',
pending_bills: '0',
pending_registrations: '0',
today_bills: '31',
today_amount: '11405',
last_bill_at: ago(7),
reported_at: ago(6),
received_at: ago(6),
printer_reachable: 'true',
storage_free_mb: '2100',
},
{
// Commissioned, heartbeating, never put into service.
terminal_id: 'RSP-03',
location_id: '1',
store_name: 'R mart — RS Puram',
app_version: '2.4.1',
status: 'online',
pending_bills: '0',
pending_registrations: '0',
today_bills: '0',
today_amount: '0',
reported_at: ago(0),
received_at: ago(0),
printer_reachable: 'true',
storage_free_mb: '3100',
},
],
2: [
{
terminal_id: 'PLM-01',
location_id: '2',
store_name: 'R mart — Peelamedu',
app_version: '2.4.1',
status: 'online',
pending_bills: '3',
pending_registrations: '0',
oldest_pending_at: ago(2),
today_bills: '112',
today_amount: '46310',
last_bill_at: ago(1),
// Reporting a time nine minutes ahead of when we received it — the till's
// clock is wrong, and its bills carry the wrong business date.
reported_at: ago(-9),
received_at: ago(0),
printer_reachable: 'true',
storage_free_mb: '1850',
},
{
terminal_id: 'PLM-02',
location_id: '2',
store_name: 'R mart — Peelamedu',
app_version: '2.3.9',
status: 'online',
pending_bills: '27',
pending_registrations: '2',
oldest_pending_at: ago(64),
today_bills: '58',
today_amount: '21470',
last_bill_at: ago(64),
reported_at: ago(0),
received_at: ago(0),
printer_reachable: 'false',
storage_free_mb: '140',
battery_level: '14',
battery_charging: 'false',
},
],
3: [
{
terminal_id: 'SRV-01',
location_id: '3',
store_name: 'R mart — Saravanampatti',
app_version: '2.3.9',
status: 'offline',
pending_bills: '41',
pending_registrations: '0',
oldest_pending_at: ago(196),
today_bills: '39',
today_amount: '15880',
last_bill_at: ago(196),
reported_at: ago(194),
received_at: ago(194),
drawer_status: 'open',
},
{
// The expired-key stub: terminal, location, status, reason — and nothing
// else. No heartbeat fields at all, which is the whole tell.
terminal_id: 'SRV-02',
location_id: '3',
status: 'offline',
reason: 'presence key expired',
},
],
};
export const DEMO_POS_SUMMARY: Record<number, PosSalesSummary> = {
1: {
locationid: 1,
billcount: 46,
itemcount: 214,
grosssales: 18240,
taxcollected: 1640,
discountgiven: 380,
roundoff: 12,
averagebill: 396.5,
bypaymentmode: [
{ paymentmode: 'UPI', amount: 11420, billcount: 31 },
{ paymentmode: 'Cash', amount: 5380, billcount: 12 },
{ paymentmode: 'Card', amount: 1440, billcount: 3 },
],
byterminal: [
{ terminalid: 'RSP-01', amount: 18240, billcount: 46 },
{ terminalid: 'RSP-02', amount: 11405, billcount: 31 },
// Billing, with no health record anywhere. Invisible on the board until
// the sales split is reconciled against it — which is the point.
{ terminalid: 'RSP-04', amount: 6820, billcount: 17 },
],
byday: [
{ day: '2026-08-17', amount: 15900, billcount: 41 },
{ day: '2026-08-18', amount: 17210, billcount: 44 },
{ day: '2026-08-19', amount: 16480, billcount: 42 },
{ day: '2026-08-20', amount: 19340, billcount: 49 },
{ day: '2026-08-21', amount: 18240, billcount: 46 },
],
},
2: {
locationid: 2,
billcount: 170,
itemcount: 806,
grosssales: 67780,
taxcollected: 6120,
discountgiven: 1450,
roundoff: 38,
averagebill: 398.7,
bypaymentmode: [
{ paymentmode: 'UPI', amount: 41260, billcount: 104 },
{ paymentmode: 'Cash', amount: 18940, billcount: 52 },
{ paymentmode: 'Card', amount: 7580, billcount: 14 },
],
byterminal: [
{ terminalid: 'PLM-01', amount: 46310, billcount: 112 },
{ terminalid: 'PLM-02', amount: 21470, billcount: 58 },
],
byday: [
{ day: '2026-08-17', amount: 61200, billcount: 158 },
{ day: '2026-08-18', amount: 64850, billcount: 163 },
{ day: '2026-08-19', amount: 59900, billcount: 151 },
{ day: '2026-08-20', amount: 71340, billcount: 178 },
{ day: '2026-08-21', amount: 67780, billcount: 170 },
],
},
3: {
locationid: 3,
billcount: 39,
itemcount: 168,
grosssales: 15880,
taxcollected: 1430,
discountgiven: 210,
roundoff: 7,
averagebill: 407.2,
bypaymentmode: [
{ paymentmode: 'Cash', amount: 9640, billcount: 26 },
{ paymentmode: 'UPI', amount: 6240, billcount: 13 },
],
byterminal: [{ terminalid: 'SRV-01', amount: 15880, billcount: 39 }],
byday: [
{ day: '2026-08-19', amount: 17420, billcount: 44 },
{ day: '2026-08-20', amount: 16900, billcount: 43 },
{ day: '2026-08-21', amount: 15880, billcount: 39 },
],
},
};
export const DEMO_STOCK_REQUESTS: StockRequest[] = [
{
requestid: 501,
tenantid: 1135,
locationid: 2,
locationname: 'R mart — Peelamedu',
productid: 9001,
productname: 'Aachi Chilli Powder 100gm',
qty: 60,
status: 'Pending',
created: ago(95),
},
{
requestid: 502,
tenantid: 1135,
locationid: 3,
locationname: 'R mart — Saravanampatti',
productid: 9004,
productname: 'Amul Butter 500g',
qty: 24,
status: 'Pending',
created: ago(240),
},
{
requestid: 503,
tenantid: 1135,
locationid: 1,
locationname: 'R mart — RS Puram',
productid: 9007,
productname: 'Cadbury Dairy Milk 50g',
qty: 100,
status: 'Pending',
created: ago(410),
},
{
requestid: 498,
tenantid: 1135,
locationid: 2,
locationname: 'R mart — Peelamedu',
productid: 9002,
productname: 'Aachi Garam Masala 100g',
qty: 40,
status: 'Received',
created: ago(1500),
},
{
requestid: 497,
tenantid: 1135,
locationid: 1,
locationname: 'R mart — RS Puram',
productid: 9005,
productname: 'Amul Cheese Slices 200g',
qty: 15,
status: 'Rejected',
created: ago(2100),
},
];
/**
* A stock statement per branch.
*
* `debit` is what left the shelf — both channels together, which is why it is
* the figure the fast/slow-mover lists rank on rather than either sales table.
*/
export const DEMO_STOCK_STATEMENT: Record<number, StockStatementRow[]> = {
1: [
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 80, credit: 40, debit: 96, closing: 24, retailprice: 35, locationid: 1 },
{ productid: 9004, productname: 'Amul Butter 500g', opening: 30, credit: 24, debit: 52, closing: 2, retailprice: 265, locationid: 1 },
{ productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 120, credit: 0, debit: 120, closing: 0, retailprice: 45, locationid: 1 },
{ productid: 9002, productname: 'Aachi Garam Masala 100g', opening: 60, credit: 20, debit: 31, closing: 49, retailprice: 42, locationid: 1 },
{ productid: 9005, productname: 'Amul Cheese Slices 200g', opening: 25, credit: 10, debit: 8, closing: 27, retailprice: 145, locationid: 1 },
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 200, credit: 0, debit: 14, closing: 186, retailprice: 20, locationid: 1 },
],
2: [
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 140, credit: 60, debit: 196, closing: 4, retailprice: 35, locationid: 2 },
{ productid: 9004, productname: 'Amul Butter 500g', opening: 60, credit: 48, debit: 91, closing: 17, retailprice: 265, locationid: 2 },
{ productid: 9007, productname: 'Cadbury Dairy Milk 50g', opening: 220, credit: 100, debit: 288, closing: 32, retailprice: 45, locationid: 2 },
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 300, credit: 0, debit: 41, closing: 259, retailprice: 20, locationid: 2 },
],
3: [
{ productid: 9004, productname: 'Amul Butter 500g', opening: 20, credit: 0, debit: 20, closing: 0, retailprice: 265, locationid: 3 },
{ productid: 9001, productname: 'Aachi Chilli Powder 100gm', opening: 70, credit: 30, debit: 88, closing: 12, retailprice: 35, locationid: 3 },
{ productid: 9009, productname: 'Nestlé Munch 32g', opening: 150, credit: 0, debit: 9, closing: 141, retailprice: 20, locationid: 3 },
],
};
/* ────────────────────────────────────────────────────────────────────────────
Sales rows — app orders, deliveries and counter bills
The order set mirrors the shape of the live tenant rather than an idealised
one: RS Puram's cancel rate is genuinely 98%, so most of its orders are
cancelled here too. A demo that only shows the happy path hides the exact
thing this page exists to surface.
──────────────────────────────────────────────────────────────────────────── */
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
/** Hours ago, as the timestamp string the API returns. */
const hoursAgo = (h: number): string =>
new Date(Date.now() - h * 3_600_000).toISOString().slice(0, 19).replace('T', ' ');
const BRANCH_NAME: Record<number, string> = {
1: 'R mart — RS Puram',
2: 'R mart — Peelamedu',
3: 'R mart — Saravanampatti',
};
const CUSTOMERS = [
{ name: 'Meena Ravi', phone: '9840012233', suburb: 'RS Puram', address: '12, Bharathi Street, RS Puram' },
{ name: 'Karthik S', phone: '9840044556', suburb: 'Peelamedu', address: '7, Nehru Nagar, Peelamedu' },
{ name: 'Divya Prakash', phone: '9840077889', suburb: 'Saravanampatti', address: '22, Sathy Road, Saravanampatti' },
{ name: 'Arun Kumar', phone: '9840099001', suburb: 'Gandhipuram', address: '90, Cross Cut Road, Gandhipuram' },
{ name: 'Lakshmi N', phone: '9840033445', suburb: 'Peelamedu', address: '3, Avinashi Road, Peelamedu' },
];
const RIDERS = ['Suresh M', 'Vignesh R', 'Prakash T'];
function order(
id: number,
locationid: number,
hours: number,
orderstatus: string,
deliverystatus: string,
amount: number,
items: number,
riderIndex: number | null,
): OrderRow {
const customer = CUSTOMERS[id % CUSTOMERS.length] as (typeof CUSTOMERS)[number];
const isDone = deliverystatus === 'Delivered';
const isMoving = deliverystatus === 'Out for Delivery' || isDone;
return {
orderheaderid: 7000 + id,
orderid: `RM${String(24000 + id)}`,
tenantid: 1135,
locationid,
locationname: BRANCH_NAME[locationid],
orderdate: hoursAgo(hours),
orderstatus,
deliverystatus,
itemcount: items,
orderamount: amount,
deliverycharge: 25,
deliverycustomer: customer.name,
deliverycontactno: customer.phone,
deliveryaddress: customer.address,
deliverysuburb: customer.suburb,
pickupcustomer: BRANCH_NAME[locationid],
pickupsuburb: customer.suburb,
deliveryid: deliverystatus ? 5000 + id : 0,
rider: riderIndex === null ? '' : RIDERS[riderIndex],
ridercontactno: riderIndex === null ? '' : '9000011122',
riderkms: isMoving ? (2 + (id % 5)).toFixed(1) : '',
assigntime: riderIndex === null ? '' : hoursAgo(hours - 0.2),
pickuptime: isMoving ? hoursAgo(hours - 0.4) : '',
deliverytime: isDone ? hoursAgo(hours - 0.9) : '',
canceltime: orderstatus === 'Cancelled' ? hoursAgo(hours - 0.3) : '',
};
}
export const DEMO_ORDERS: OrderRow[] = [
// Peelamedu — the branch that actually trades well.
order(1, 2, 0.4, 'Confirmed', 'Out for Delivery', 862, 9, 0),
order(2, 2, 1.1, 'Delivered', 'Delivered', 1240, 14, 1),
order(3, 2, 2.3, 'Delivered', 'Delivered', 415, 5, 0),
order(4, 2, 3.0, 'Preparing', 'Pending', 690, 7, null),
order(5, 2, 4.6, 'Delivered', 'Delivered', 2180, 22, 2),
order(6, 2, 6.2, 'Cancelled', 'Cancelled', 320, 4, null),
order(7, 2, 8.0, 'Delivered', 'Delivered', 975, 11, 1),
// Saravanampatti — steady, a couple in flight.
order(8, 3, 0.8, 'Ready', 'Pending', 540, 6, null),
order(9, 3, 2.0, 'Confirmed', 'Out for Delivery', 1105, 12, 2),
order(10, 3, 5.4, 'Delivered', 'Delivered', 780, 8, 0),
order(11, 3, 9.1, 'Cancelled', 'Cancelled', 260, 3, null),
// RS Puram — the 98% cancel rate, which is the finding worth surfacing.
order(12, 1, 1.5, 'Cancelled', 'Cancelled', 180, 2, null),
order(13, 1, 3.7, 'Cancelled', 'Cancelled', 340, 4, null),
order(14, 1, 5.9, 'Cancelled', 'Cancelled', 95, 1, null),
order(15, 1, 7.2, 'Cancelled', 'Cancelled', 420, 5, null),
order(16, 1, 10.5, 'Delivered', 'Delivered', 1350, 15, 1),
];
function bill(
id: number,
locationid: number,
terminalid: string,
cashier: string,
hours: number,
total: number,
items: number,
mode: string,
customer?: string,
): PosSale {
return {
posorderid: 88000 + id,
terminalorderid: `b7f${String(id).padStart(4, '0')}-4a2c-11f0-9cd2-0242ac120002`,
invoicenumber: `${terminalid}/${String(1200 + id)}`,
tenantid: 1135,
locationid,
terminalid,
cashiername: cashier,
customerid: customer ? 4000 + id : 0,
customername: customer ?? '',
billedat: hoursAgo(hours),
businessdate: new Date().toISOString().slice(0, 10),
subtotal: Math.round(total * 0.91),
discount: Math.round(total * 0.02),
taxamount: Math.round(total * 0.09),
roundoff: 0.4,
total,
itemcount: items,
paymentmode: mode,
batchid: `batch-${locationid}-${Math.ceil(hours)}`,
receivedat: hoursAgo(hours - 0.01),
};
}
export const DEMO_POS_BILLS: Record<number, PosSale[]> = {
1: [
bill(1, 1, 'RSP-01', 'Anitha', 0.2, 486, 6, 'UPI', 'Meena Ravi'),
bill(2, 1, 'RSP-01', 'Anitha', 0.9, 1240, 14, 'Cash'),
bill(3, 1, 'RSP-01', 'Anitha', 1.8, 312, 3, 'UPI'),
bill(4, 1, 'RSP-01', 'Ramesh', 3.4, 875, 9, 'Card', 'Arun Kumar'),
bill(5, 1, 'RSP-01', 'Ramesh', 5.1, 204, 2, 'UPI'),
],
2: [
bill(6, 2, 'PLM-01', 'Kavitha', 0.1, 1580, 18, 'UPI', 'Karthik S'),
bill(7, 2, 'PLM-01', 'Kavitha', 0.6, 640, 7, 'Cash'),
bill(8, 2, 'PLM-02', 'Selvam', 1.2, 2340, 26, 'Card', 'Lakshmi N'),
bill(9, 2, 'PLM-01', 'Kavitha', 2.5, 415, 4, 'UPI'),
bill(10, 2, 'PLM-02', 'Selvam', 4.0, 980, 11, 'Cash'),
bill(11, 2, 'PLM-01', 'Kavitha', 6.3, 1120, 13, 'UPI', 'Divya Prakash'),
],
3: [
bill(12, 3, 'SRV-01', 'Bhuvana', 3.3, 720, 8, 'Cash', 'Divya Prakash'),
bill(13, 3, 'SRV-01', 'Bhuvana', 4.8, 395, 4, 'UPI'),
bill(14, 3, 'SRV-01', 'Bhuvana', 7.5, 1260, 15, 'Cash'),
],
};
/**
* Delivery jobs — a SEPARATE fixture from the orders above, because they are a
* separate read with a different shape.
*
* Two things are deliberately imperfect. `actualkms` exceeds `kms` on a couple
* of jobs, because that gap is the reason the table shows both. And the delivery
* status uses its own ladder (accepted / picked / active), not the order one —
* a demo that reused the order words would make the two maps look redundant.
*/
export const DEMO_DELIVERIES: DeliveryRow[] = [
{
deliveryid: 5001, orderheaderid: 7001, orderid: 'RM24001', tenantid: 1135, locationid: 2,
deliverydate: hoursAgo(0.7),
locationname: 'R mart — Peelamedu', orderstatus: 'active',
assigntime: hoursAgo(0.6), starttime: hoursAgo(0.55), pickuptime: hoursAgo(0.4),
itemcount: 9, orderamount: 862, deliveryamt: 862, deliverycharges: 45,
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu', pickupcontactno: '9879879877',
deliverycustomer: 'Karthik S', deliverycontactno: '9840044556',
deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu',
ridername: 'Suresh M', ridercontact: '9000011122', kms: '3.0', actualkms: '3.4',
transitminutes: 18, ordernotes: 'Call on arrival, gate code 4412',
},
{
deliveryid: 5002, orderheaderid: 7002, orderid: 'RM24002', tenantid: 1135, locationid: 2,
deliverydate: hoursAgo(1.4),
locationname: 'R mart — Peelamedu', orderstatus: 'delivered',
assigntime: hoursAgo(1.3), pickuptime: hoursAgo(1.1), deliverytime: hoursAgo(0.6),
itemcount: 14, orderamount: 1240, deliveryamt: 1240, deliverycharges: 55,
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889',
deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti',
ridername: 'Vignesh R', ridercontact: '9000011133', kms: '4.0', actualkms: '9.2',
transitminutes: 41, notes: 'Wrong pin dropped, rider re-routed',
},
{
deliveryid: 5003, orderheaderid: 7005, orderid: 'RM24005', tenantid: 1135, locationid: 2,
deliverydate: hoursAgo(4.9),
locationname: 'R mart — Peelamedu', orderstatus: 'delivered',
assigntime: hoursAgo(4.8), pickuptime: hoursAgo(4.6), deliverytime: hoursAgo(3.9),
itemcount: 22, orderamount: 2180, deliveryamt: 2180, deliverycharges: 60,
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233',
deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram',
ridername: 'Prakash T', ridercontact: '9000011144', kms: '2.0', actualkms: '2.1',
transitminutes: 14,
},
{
deliveryid: 5004, orderheaderid: 7004, orderid: 'RM24004', tenantid: 1135, locationid: 2,
deliverydate: hoursAgo(3.1),
locationname: 'R mart — Peelamedu', orderstatus: 'pending',
itemcount: 7, orderamount: 690, deliveryamt: 690, deliverycharges: 0,
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445',
deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu',
kms: '1.8', ordernotes: 'Waiting on packing',
},
{
deliveryid: 5005, orderheaderid: 7009, orderid: 'RM24009', tenantid: 1135, locationid: 3,
deliverydate: hoursAgo(2.3),
locationname: 'R mart — Saravanampatti', orderstatus: 'picked',
assigntime: hoursAgo(2.2), pickuptime: hoursAgo(1.9),
itemcount: 12, orderamount: 1105, deliveryamt: 1105, deliverycharges: 50,
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
deliverycustomer: 'Lakshmi N', deliverycontactno: '9840033445',
deliveryaddress: '3, Avinashi Road, Peelamedu', deliverysuburb: 'Peelamedu',
ridername: 'Prakash T', ridercontact: '9000011144', kms: '6.0', actualkms: '6.3',
transitminutes: 27,
},
{
deliveryid: 5006, orderheaderid: 7008, orderid: 'RM24008', tenantid: 1135, locationid: 3,
deliverydate: hoursAgo(1.0),
locationname: 'R mart — Saravanampatti', orderstatus: 'accepted',
assigntime: hoursAgo(0.9),
itemcount: 6, orderamount: 540, deliveryamt: 540, deliverycharges: 40,
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
deliverycustomer: 'Arun Kumar', deliverycontactno: '9840099001',
deliveryaddress: '90, Cross Cut Road, Gandhipuram', deliverysuburb: 'Gandhipuram',
ridername: 'Suresh M', ridercontact: '9000011122', kms: '5.5',
},
{
deliveryid: 5007, orderheaderid: 7010, orderid: 'RM24010', tenantid: 1135, locationid: 3,
deliverydate: hoursAgo(5.7),
locationname: 'R mart — Saravanampatti', orderstatus: 'delivered',
assigntime: hoursAgo(5.6), pickuptime: hoursAgo(5.4), deliverytime: hoursAgo(4.8),
itemcount: 8, orderamount: 780, deliveryamt: 780, deliverycharges: 45,
pickupcustomer: 'R mart — Saravanampatti', pickupsuburb: 'Saravanampatti',
deliverycustomer: 'Meena Ravi', deliverycontactno: '9840012233',
deliveryaddress: '12, Bharathi Street, RS Puram', deliverysuburb: 'RS Puram',
ridername: 'Suresh M', ridercontact: '9000011122', kms: '2.0', actualkms: '2.4',
transitminutes: 16,
},
{
deliveryid: 5008, orderheaderid: 7006, orderid: 'RM24006', tenantid: 1135, locationid: 2,
deliverydate: hoursAgo(6.2),
locationname: 'R mart — Peelamedu', orderstatus: 'cancelled',
canceltime: hoursAgo(6.0),
itemcount: 4, orderamount: 320, deliveryamt: 0, deliverycharges: 0,
pickupcustomer: 'R mart — Peelamedu', pickupsuburb: 'Peelamedu',
deliverycustomer: 'Karthik S', deliverycontactno: '9840044556',
deliveryaddress: '7, Nehru Nagar, Peelamedu', deliverysuburb: 'Peelamedu',
kms: '3.0', notes: 'Customer cancelled before pickup',
},
{
deliveryid: 5009, orderheaderid: 7012, orderid: 'RM24012', tenantid: 1135, locationid: 1,
deliverydate: hoursAgo(1.7),
locationname: 'R mart — RS Puram', orderstatus: 'skipped',
assigntime: hoursAgo(1.6),
itemcount: 2, orderamount: 180, deliveryamt: 180, deliverycharges: 35,
pickupcustomer: 'R mart — RS Puram', pickupsuburb: 'RS Puram',
deliverycustomer: 'Divya Prakash', deliverycontactno: '9840077889',
deliveryaddress: '22, Sathy Road, Saravanampatti', deliverysuburb: 'Saravanampatti',
ridername: 'Vignesh R', kms: '7.2', notes: 'Nobody at address, returned to store',
},
];
/* ────────────────────────────────────────────────────────────────────────────
People — back office and till accounts
Two separate sets on purpose, because they are two separate account systems.
Note the till accounts carry NO password or pin: the backend returns those
only in the answer to a creation, never in a listing, and a fixture that
handed them back in a list would teach the UI a habit the real API refuses.
──────────────────────────────────────────────────────────────────────────── */
import type { PosRole, PosUser, StaffInfo, StaffShift } from '@/api/types';
export const DEMO_STAFF: StaffInfo[] = [
{
userid: 9002, rolename: 'Administrator', roleid: 1, fullname: 'Rmart Operations',
firstname: 'Rmart', lastname: 'Operations', email: 'rmart@gmail.com', contactno: '9876543210',
tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active',
},
{
userid: 9010, rolename: 'Manager', roleid: 4, fullname: 'Anitha Selvam',
firstname: 'Anitha', lastname: 'Selvam', email: 'anitha@rmart.com', contactno: '9840111222',
tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', status: 'Active',
},
{
userid: 9011, rolename: 'Manager', roleid: 4, fullname: 'Vijay Anand',
firstname: 'Vijay', lastname: 'Anand', email: 'vijay@rmart.com', contactno: '9840111333',
tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active',
},
{
userid: 9012, rolename: 'Staff', roleid: 5, fullname: 'Priya Raman',
firstname: 'Priya', lastname: 'Raman', email: 'priya@rmart.com', contactno: '9840111444',
tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', status: 'Active',
},
{
userid: 9013, rolename: 'Staff', roleid: 5, fullname: 'Mohan Das',
firstname: 'Mohan', lastname: 'Das', email: 'mohan@rmart.com', contactno: '9840111555',
tenantid: 1135, locationid: 3, locationname: 'R mart — Saravanampatti', status: 'Inactive',
},
];
export const DEMO_POS_ROLES: PosRole[] = [
{
role_id: 7, role: 'supervisor', label: 'Supervisor',
description:
'Runs the terminal: imports, settings, voids, and creating counter staff. ' +
'Signs in at a till only — a till account has no Nearle Daily login.',
},
{
role_id: 8, role: 'cashier', label: 'Cashier',
description: 'Billing only.',
},
];
export const DEMO_SHIFTS: Record<number, StaffShift[]> = {
1: [
{ staff_shift_id: 11, tenantid: 1135, locationid: 1, name: 'Morning', start_time: '08:00', end_time: '15:00', weekdays: '1111100', status: 'Active' },
{ staff_shift_id: 12, tenantid: 1135, locationid: 1, name: 'Evening', start_time: '15:00', end_time: '22:00', weekdays: '1111111', status: 'Active' },
],
2: [
{ staff_shift_id: 13, tenantid: 1135, locationid: 2, name: 'Morning', start_time: '07:00', end_time: '15:00', weekdays: '1111111', status: 'Active' },
{ staff_shift_id: 14, tenantid: 1135, locationid: 2, name: 'Late', start_time: '15:00', end_time: '23:00', weekdays: '1111111', status: 'Active' },
],
3: [
{ staff_shift_id: 15, tenantid: 1135, locationid: 3, name: 'Full day', start_time: '08:00', end_time: '22:00', weekdays: '', status: 'Active' },
],
};
export const DEMO_POS_USERS: Record<number, PosUser[]> = {
1: [
{ user_id: 7001, full_name: 'Anitha Selvam', role: 'supervisor', role_id: 7, contactno: '9840111222', has_password: true, shift_id: 11, shift_name: 'Morning', shift_start: '08:00', shift_end: '15:00', location_id: 1, status: 'Active' },
{ user_id: 7002, full_name: 'Ramesh K', role: 'cashier', role_id: 8, contactno: '9840222111', has_password: true, shift_id: 12, shift_name: 'Evening', shift_start: '15:00', shift_end: '22:00', location_id: 1, status: 'Active' },
],
2: [
{ user_id: 7003, full_name: 'Kavitha M', role: 'supervisor', role_id: 7, contactno: '9840333111', has_password: true, shift_id: 13, shift_name: 'Morning', shift_start: '07:00', shift_end: '15:00', location_id: 2, status: 'Active' },
{ user_id: 7004, full_name: 'Selvam R', role: 'cashier', role_id: 8, contactno: '9840333222', has_password: true, shift_id: 14, shift_name: 'Late', shift_start: '15:00', shift_end: '23:00', location_id: 2, status: 'Active' },
{ user_id: 7005, full_name: 'Deepa N', role: 'cashier', role_id: 8, contactno: '9840333444', has_password: false, location_id: 2, status: 'Inactive' },
],
3: [
{ user_id: 7006, full_name: 'Bhuvana S', role: 'supervisor', role_id: 7, contactno: '9840444111', has_password: true, shift_id: 15, shift_name: 'Full day', shift_start: '08:00', shift_end: '22:00', location_id: 3, status: 'Active' },
],
};

217
src/demo/index.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Demo mode — three signed-in roles and a fixture backend, for working on the
* console without reaching Fiesta.
*
* WHY THIS EXISTS. The API is wired and correct, but it has never been
* exercised end to end from a development machine that cannot reach
* `fiesta.nearle.app`, and the Store Admin and Store Manager workspaces have no
* accounts to sign into yet. Rather than leave every screen showing an error,
* demo mode seeds a session and answers the same endpoints from fixtures.
*
* HOW IT IS GATED — two locks, and both must be open:
*
* 1. `import.meta.env.DEV` — a build-time constant. In a production build
* this is `false`, so every branch below is dead code that the bundler
* removes entirely. The fixtures never ship.
* 2. A `sessionStorage` flag the person sets by clicking a demo button. Even
* in development, a normal sign-in against the real API is unaffected
* unless demo mode was explicitly chosen.
*
* This is a development affordance, NOT an authentication bypass: it cannot be
* reached in a production build at all, and it grants nothing on the server —
* it only decides what this browser tab renders.
*/
const FLAG = 'nearle.demo.v1';
/** Compile-time gate. `false` in a production build, so everything below drops. */
export const DEMO_AVAILABLE = import.meta.env.DEV;
export function isDemoActive(): boolean {
if (!DEMO_AVAILABLE) return false;
try {
return sessionStorage.getItem(FLAG) === 'on';
} catch {
return false;
}
}
export function enableDemo(): void {
sessionStorage.setItem(FLAG, 'on');
}
export function disableDemo(): void {
sessionStorage.removeItem(FLAG);
}
/* ────────────────────────────────────────────────────────────────────────────
The fixture backend
──────────────────────────────────────────────────────────────────────────── */
/**
* Answers a request from fixtures, or returns `MISS` to let the real call go
* out.
*
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
* answering `undefined` and an endpoint we have no fixture for are different
* things, and collapsing them would silently swallow the second.
*/
export const MISS = Symbol('demo-miss');
/**
* Answers a request from fixtures, or returns `MISS` to let the real call go
* out.
*
* `MISS` is a sentinel rather than `undefined` because an endpoint legitimately
* answering `undefined` and an endpoint we have no fixture for are different
* things, and collapsing them would silently swallow the second.
*
* The fixtures are behind a dynamic import INSIDE the `import.meta.env.DEV`
* guard, and that is load-bearing rather than stylistic: with a static import
* the bundler cannot prove the module is unreachable, so the whole fixture set
* ships to production. Verified by grepping the built assets for fixture
* strings — see the note in the README.
*/
export async function demoResolve(
path: string,
params: Record<string, unknown> | undefined,
): Promise<unknown | typeof MISS> {
if (!import.meta.env.DEV) return MISS;
const f = await import('./fixtures');
const tenantid = Number(params?.['tenantid'] ?? 0);
if (path.endsWith('/tenants/getalltenants')) return f.DEMO_TENANTS;
if (path.endsWith('/tenants/gettenantlocations')) {
return f.DEMO_LOCATIONS[tenantid] ?? [];
}
if (path.endsWith('/catalogue/getproducts')) {
const brand = String(params?.['brand'] ?? '');
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_CATALOGUE.filter(
(product) =>
(brand === '' || product.brand === brand) &&
(keyword === '' || product.product_name.toLowerCase().includes(keyword)),
);
}
if (path.endsWith('/catalogue/getbrands')) return f.DEMO_BRANDS;
if (path.endsWith('/products/getimportedcatalogueproducts')) {
return f.DEMO_IMPORTED[tenantid] ?? [];
}
if (path.endsWith('/products/getproductcategories')) return f.DEMO_CATEGORIES;
if (path.endsWith('/products/getlocationproducts')) return f.DEMO_STORE_PRODUCTS;
if (path.endsWith('/orders/getlocationsummary')) {
return f.DEMO_LOCATION_SUMMARY[tenantid] ?? [];
}
if (path.endsWith('/orders/getordersummary')) {
return f.DEMO_ORDER_SUMMARY[tenantid] ?? {};
}
/* ── Store Admin ──────────────────────────────────────────────────────── */
if (path.endsWith('/orders/tenant/getorders') || path.endsWith('/orders/getorders')) {
const locationid = Number(params?.['locationid'] ?? 0);
const status = String(params?.['status'] ?? '');
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_ORDERS.filter(
(order) =>
(!locationid || order.locationid === locationid) &&
(status === '' || order.orderstatus === status) &&
(keyword === '' ||
`${order.orderid} ${order.deliverycustomer}`.toLowerCase().includes(keyword)),
);
}
if (path.endsWith('/deliveries/getdeliveries')) {
const locationid = Number(params?.['locationid'] ?? 0);
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
return f.DEMO_DELIVERIES.filter(
(job) =>
(!locationid || job.locationid === locationid) &&
(keyword === '' ||
`${job.orderid} ${job.deliverycustomer} ${job.ridername}`
.toLowerCase()
.includes(keyword)),
);
}
if (path.includes('/pos/sales') && !path.includes('summary') && !path.includes('detail')) {
const locationid = Number(params?.['locationid'] ?? 0);
const bills = f.DEMO_POS_BILLS[locationid] ?? [];
return { total: bills.length, pageno: 0, pagesize: 100, bills };
}
if (path.includes('/health/location')) {
const locationid = Number(params?.['location_id'] ?? 0);
return f.DEMO_POS_HEALTH[locationid] ?? [];
}
if (path.includes('/sales/summary')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_POS_SUMMARY[locationid] ?? {};
}
if (path.endsWith('/tenants/getstaffs')) return f.DEMO_STAFF;
if (path.endsWith('/posroles')) return f.DEMO_POS_ROLES;
if (path.endsWith('/getposusers')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_POS_USERS[locationid] ?? [];
}
if (path.endsWith('/getstaffshifts')) {
const locationid = Number(params?.['locationid'] ?? 0);
return f.DEMO_SHIFTS[locationid] ?? [];
}
// A created till account answers with its PIN once — the same shape the real
// endpoint uses, so the "shown once" screen is exercised in demo too.
if (path.includes('/createposuser')) {
return { user_id: 7099, full_name: 'New till user', role: 'cashier', status: 'Active', pin: '4821' };
}
if (path.includes('/updateposuser')) return { user_id: 7099, status: 'Active' };
if (path.includes('/users/create')) return { userid: 9099, status: 'Active' };
if (path.includes('/users/update')) return { userid: 9099, status: 'Active' };
if (path.endsWith('/products/getstockrequests')) {
const locationid = Number(params?.['locationid'] ?? 0);
const status = String(params?.['status'] ?? '');
return f.DEMO_STOCK_REQUESTS.filter(
(request) =>
(!locationid || request.locationid === locationid) &&
(status === '' || request.status === status),
);
}
if (path.endsWith('/products/getstockstatement')) {
const locationid = Number(params?.['locationid'] ?? 0);
const keyword = String(params?.['keyword'] ?? '').toLowerCase();
const rows = f.DEMO_STOCK_STATEMENT[locationid] ?? [];
return keyword === ''
? rows
: rows.filter((row) => (row.productname ?? '').toLowerCase().includes(keyword));
}
if (path.includes('/updatestockrequest')) return { updated: true };
// Writes acknowledge without persisting: the point of demo mode is to walk
// the screens, and a form that silently fails teaches the wrong thing.
if (path.includes('/createtenantlocation')) {
return { tenantid: 1140, locationid: 20, tenantname: 'New tenant', status: 'Active' };
}
if (path.includes('/createlocation')) {
return { locationid: 21, tenantid, locationname: 'New branch', status: 'Active' };
}
if (path.includes('/importcatalogueproduct')) return { imported: true };
return MISS;
}