pos integration
This commit is contained in:
@@ -52,6 +52,7 @@ import InventoryView from './components/InventoryView';
|
|||||||
import SettingsView from './components/SettingsView';
|
import SettingsView from './components/SettingsView';
|
||||||
import StoreDetailView from './components/StoreDetailView';
|
import StoreDetailView from './components/StoreDetailView';
|
||||||
import DispatchHubView from './components/DispatchHubView';
|
import DispatchHubView from './components/DispatchHubView';
|
||||||
|
import PosConsoleView from './components/PosConsoleView';
|
||||||
import LoginView from './components/LoginView';
|
import LoginView from './components/LoginView';
|
||||||
import UserStorePage from './components/UserStorePage';
|
import UserStorePage from './components/UserStorePage';
|
||||||
import SuperAdminPage from './components/SuperAdminPage';
|
import SuperAdminPage from './components/SuperAdminPage';
|
||||||
@@ -672,6 +673,12 @@ export default function App() {
|
|||||||
<DispatchHubView tenantId={tenantId} />
|
<DispatchHubView tenantId={tenantId} />
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Tenant-wide POS: no locationid, so the view fans out over every
|
||||||
|
outlet under the tenant. */}
|
||||||
|
<Route path="pos" element={
|
||||||
|
<PosConsoleView tenantId={tenantId} />
|
||||||
|
} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
1668
src/components/PosConsoleView.tsx
Normal file
1668
src/components/PosConsoleView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Users,
|
Users,
|
||||||
Truck,
|
Truck,
|
||||||
Box
|
Box,
|
||||||
|
Monitor
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
@@ -40,6 +41,7 @@ export default function Sidebar({
|
|||||||
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
||||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||||
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
||||||
|
{ id: 'pos' as MainSection, label: 'POS', icon: Monitor },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
Monitor,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import DispatchHubView from './DispatchHubView';
|
|||||||
import DeliveryReportsView from './DeliveryReportsView';
|
import DeliveryReportsView from './DeliveryReportsView';
|
||||||
import StoreQRView from './StoreQRView';
|
import StoreQRView from './StoreQRView';
|
||||||
import PosView from './PosView';
|
import PosView from './PosView';
|
||||||
|
import PosConsoleView from './PosConsoleView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
import ComparisonModal from './ComparisonModal';
|
import ComparisonModal from './ComparisonModal';
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
@@ -52,6 +54,7 @@ const NAV_ITEMS: UserNavItem[] = [
|
|||||||
{ id: 'inventory', label: 'Products', icon: Layers },
|
{ id: 'inventory', label: 'Products', icon: Layers },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
|
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
|
||||||
|
{ id: 'pos', label: 'POS', icon: Monitor },
|
||||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -114,6 +117,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
const resolvedLocationId =
|
const resolvedLocationId =
|
||||||
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||||
|
|
||||||
|
// The POS reads treat `locationid` as their authorisation boundary, so this
|
||||||
|
// deliberately does NOT inherit the fallback chain above: `applocationid` is
|
||||||
|
// a city id, not an outlet, and passing one would scope the query to a store
|
||||||
|
// that is either empty or — if the ids happen to collide — somebody else's.
|
||||||
|
// Only a real tenantlocations.locationid is accepted; otherwise the POS page
|
||||||
|
// says so rather than showing another shop's takings.
|
||||||
|
const posLocationId = (matchedLoc && fnum(matchedLoc.locationid)) || 0;
|
||||||
|
|
||||||
const orderSummaryQ = useFiestaOrderSummary(tenantId, todayStr, todayStr, resolvedLocationId || undefined);
|
const orderSummaryQ = useFiestaOrderSummary(tenantId, todayStr, todayStr, resolvedLocationId || undefined);
|
||||||
const sum = orderSummaryQ.data;
|
const sum = orderSummaryQ.data;
|
||||||
|
|
||||||
@@ -256,7 +267,10 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
// Logistics console — scoped to this user's store. These views own their
|
// Logistics console — scoped to this user's store. These views own their
|
||||||
// loading/error states, so they don't need the store-console load gating below.
|
// loading/error states, so they don't need the store-console load gating below.
|
||||||
if (activeSection === 'pos') return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
if (activeSection === 'pos')
|
||||||
|
return <PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />;
|
||||||
|
if (activeSection === 'pos-till')
|
||||||
|
return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
@@ -302,6 +316,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
: activeSection === 'inventory'
|
: activeSection === 'inventory'
|
||||||
? 'Products'
|
? 'Products'
|
||||||
: activeSection === 'pos'
|
: activeSection === 'pos'
|
||||||
|
? 'POS'
|
||||||
|
: activeSection === 'pos-till'
|
||||||
? 'POS Terminal'
|
? 'POS Terminal'
|
||||||
: activeSection === 'account'
|
: activeSection === 'account'
|
||||||
? 'My Account'
|
? 'My Account'
|
||||||
@@ -313,6 +329,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
: activeSection === 'inventory'
|
: activeSection === 'inventory'
|
||||||
? Layers
|
? Layers
|
||||||
: activeSection === 'pos'
|
: activeSection === 'pos'
|
||||||
|
? Monitor
|
||||||
|
: activeSection === 'pos-till'
|
||||||
? ShoppingBag
|
? ShoppingBag
|
||||||
: activeSection === 'customers'
|
: activeSection === 'customers'
|
||||||
? Users
|
? Users
|
||||||
@@ -333,7 +351,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
<main
|
<main
|
||||||
className={`relative flex-1 min-w-0 transition-all duration-300 ${
|
className={`relative flex-1 min-w-0 transition-all duration-300 ${
|
||||||
isInactive || activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
isInactive || activeSection === 'dispatch' || activeSection === 'pos-till' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
||||||
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'}`}
|
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'}`}
|
||||||
>
|
>
|
||||||
{isInactive && (
|
{isInactive && (
|
||||||
@@ -354,7 +372,16 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
{renderAccount()}
|
{renderAccount()}
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
|
{/* Counter sales and till health for this user's own store. */}
|
||||||
<Route path="pos" element={
|
<Route path="pos" element={
|
||||||
|
<div className={`w-full p-container-margin md:p-xl transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
|
<PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
{/* The on-screen till itself. Kept at its own path — it is a
|
||||||
|
checkout surface, not a reporting one, and shares nothing with
|
||||||
|
the POS console above. */}
|
||||||
|
<Route path="pos-till" element={
|
||||||
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
<PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -426,3 +453,41 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store-scoped POS console, behind a strict location gate.
|
||||||
|
*
|
||||||
|
* Kept as its own component so the unresolved-outlet case has somewhere honest
|
||||||
|
* to live. Every POS read is authorised by `locationid` alone, so a page that
|
||||||
|
* guessed one — from an applocationid, say — would not fail loudly; it would
|
||||||
|
* quietly show the wrong shop's takings, or an empty board that looks like a
|
||||||
|
* quiet day. Refusing to render is the safe answer.
|
||||||
|
*/
|
||||||
|
function PosStoreSection({
|
||||||
|
locationid,
|
||||||
|
tenantId,
|
||||||
|
storeName,
|
||||||
|
}: {
|
||||||
|
locationid: number;
|
||||||
|
tenantId: number;
|
||||||
|
storeName: string;
|
||||||
|
}) {
|
||||||
|
if (!locationid) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||||
|
<AlertTriangle size={30} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">No outlet resolved</h1>
|
||||||
|
<p className="text-[15px] text-slate-500 leading-relaxed">
|
||||||
|
Counter sales are scoped to a single outlet, and your account isn’t linked to one yet.
|
||||||
|
Ask your administrator to allocate you to a store location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PosConsoleView tenantId={tenantId} locationid={locationid} storeName={storeName} />;
|
||||||
|
}
|
||||||
|
|||||||
644
src/services/posApi.ts
Normal file
644
src/services/posApi.ts
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POS REST client — the counter-sales surface served at
|
||||||
|
* `https://fiesta.nearle.app/live/api/v1/pos`.
|
||||||
|
*
|
||||||
|
* This is a *different base* from `./fiestaApi` (which is `/live/api/v1/web`),
|
||||||
|
* so it gets its own client rather than a helper on that one.
|
||||||
|
*
|
||||||
|
* Two things about this API shape every function below:
|
||||||
|
*
|
||||||
|
* 1. **There is no tenant parameter.** `locationid` is required on every read
|
||||||
|
* and is the authorisation boundary server-side. A tenant-wide view is
|
||||||
|
* therefore a fan-out, one request per outlet, merged here — see `mapLimit`.
|
||||||
|
*
|
||||||
|
* 2. **The health endpoints return a Redis hash**, so every value is a string —
|
||||||
|
* `"today_amount": "170"`, `"printer_reachable": "0"`. Nothing may be used
|
||||||
|
* as a number or a boolean without going through the parsers here.
|
||||||
|
*
|
||||||
|
* Components call the hooks in `./posQueries`, not these functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POS_BASE = import.meta.env.VITE_FIESTA_POS_URL || 'https://fiesta.nearle.app/live/api/v1/pos';
|
||||||
|
|
||||||
|
type QueryParams = Record<string, string | number | undefined | null>;
|
||||||
|
|
||||||
|
// ── Wire types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One counter bill. Mirrors `models.PosOrders` — the list endpoint is a
|
||||||
|
* `SELECT *`, so every column arrives whether the doc's example shows it or not. */
|
||||||
|
export interface PosBill {
|
||||||
|
posorderid: number;
|
||||||
|
terminalorderid: string;
|
||||||
|
invoicenumber: string;
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
terminalid: string;
|
||||||
|
cashiername: string;
|
||||||
|
customerid: number;
|
||||||
|
customermobile: string;
|
||||||
|
customername: string;
|
||||||
|
billedat: string;
|
||||||
|
businessdate: string;
|
||||||
|
subtotal: number;
|
||||||
|
discount: number;
|
||||||
|
taxamount: number;
|
||||||
|
roundoff: number;
|
||||||
|
total: number;
|
||||||
|
pointsearned: number;
|
||||||
|
pointsredeemed: number;
|
||||||
|
itemcount: number;
|
||||||
|
paymentmode: string;
|
||||||
|
paymentsjson: string;
|
||||||
|
promosjson: string;
|
||||||
|
taxbreakdownjson: string;
|
||||||
|
batchid: string;
|
||||||
|
receivedat: string;
|
||||||
|
created?: string;
|
||||||
|
updated?: string;
|
||||||
|
/** Populated only by `/sales/detail`. The list endpoint sends `items: null`
|
||||||
|
* (the Go field is `gorm:"-"` with no `omitempty`), so this is nullable. */
|
||||||
|
items?: PosBillItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosBillItem {
|
||||||
|
posorderitemid: number;
|
||||||
|
posorderid: number;
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
productid: number;
|
||||||
|
productname: string;
|
||||||
|
barcode: string;
|
||||||
|
unitname: string;
|
||||||
|
/** Fractional — a counter sells 1.5 kg. */
|
||||||
|
quantity: number;
|
||||||
|
unitprice: number;
|
||||||
|
discountamount: number;
|
||||||
|
/** A fraction (0.18), not a percentage (18). */
|
||||||
|
gstrate: number;
|
||||||
|
taxamount: number;
|
||||||
|
linetotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosSalesPage {
|
||||||
|
total: number;
|
||||||
|
pageno: number;
|
||||||
|
pagesize: number;
|
||||||
|
bills: PosBill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosPaymentTotal { paymentmode: string; billcount: number; amount: number }
|
||||||
|
export interface PosDayTotal { businessdate: string; billcount: number; amount: number }
|
||||||
|
export interface PosTerminalTotal { terminalid: string; billcount: number; amount: number }
|
||||||
|
|
||||||
|
export interface PosSalesSummary {
|
||||||
|
locationid: number;
|
||||||
|
fromdate: string;
|
||||||
|
todate: string;
|
||||||
|
billcount: number;
|
||||||
|
itemcount: number;
|
||||||
|
/** Named "gross" by the API but computed as `SUM(total)` — i.e. **net of
|
||||||
|
* discount and round-off**. `grosssales + discountgiven` is the gross figure.
|
||||||
|
* Labelled honestly in the UI as "Net collected". */
|
||||||
|
grosssales: number;
|
||||||
|
taxcollected: number;
|
||||||
|
discountgiven: number;
|
||||||
|
roundoff: number;
|
||||||
|
averagebill: number;
|
||||||
|
bypaymentmode: PosPaymentTotal[];
|
||||||
|
byday: PosDayTotal[];
|
||||||
|
byterminal: PosTerminalTotal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw Redis hash for one till. Every value is a string; use `parseTerminal`. */
|
||||||
|
export type PosTerminalRaw = Record<string, string>;
|
||||||
|
|
||||||
|
export interface PosLocationHealth {
|
||||||
|
location_id: string;
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
terminals: PosTerminalRaw[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosSalesFilter {
|
||||||
|
locationid: number;
|
||||||
|
fromdate?: string;
|
||||||
|
todate?: string;
|
||||||
|
terminalid?: string;
|
||||||
|
/** A real filter the handler reads, applied as an exact case-sensitive match. */
|
||||||
|
cashiername?: string;
|
||||||
|
paymentmode?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function posGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && v !== '') qs.append(k, String(v));
|
||||||
|
});
|
||||||
|
const query = qs.toString();
|
||||||
|
const res = await fetch(`${POS_BASE}/${endpoint}${query ? `?${query}` : ''}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
// 404 from /sales/detail is a legitimate "no such bill at this outlet",
|
||||||
|
// not a transport failure — surfaced as null by getPosSaleDetail.
|
||||||
|
throw new Error(`POS ${endpoint} failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sales reads use the `{code, status, details}` envelope. Note there is no
|
||||||
|
* `message` key on success — only on errors. */
|
||||||
|
function details<T>(json: unknown): T | null {
|
||||||
|
if (json && typeof json === 'object' && 'details' in json) {
|
||||||
|
return (json as { details: T }).details ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` over `items` with at most `limit` in flight.
|
||||||
|
*
|
||||||
|
* The tenant views fan out one request per outlet. The largest tenant on the
|
||||||
|
* platform has 22 outlets, so an unbounded fan-out would open 44 sockets at
|
||||||
|
* once (health + summary) on every refresh. Six keeps it civil without making
|
||||||
|
* the page feel serial.
|
||||||
|
*/
|
||||||
|
export async function mapLimit<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const out = new Array<R>(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const i = cursor++;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
out[i] = await fn(items[i], i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of bills for one outlet, newest first.
|
||||||
|
*
|
||||||
|
* `pagesize` is clamped to 500 here because the server does *not* clamp — it
|
||||||
|
* resets anything over 500 back to 50, so asking for 1000 quietly returns 50.
|
||||||
|
*/
|
||||||
|
export async function getPosSales(filter: PosSalesFilter): Promise<PosSalesPage> {
|
||||||
|
const pagesize = Math.min(Math.max(filter.pagesize ?? 50, 1), 500);
|
||||||
|
const json = await posGet('sales', {
|
||||||
|
locationid: filter.locationid,
|
||||||
|
fromdate: filter.fromdate,
|
||||||
|
todate: filter.todate,
|
||||||
|
terminalid: filter.terminalid,
|
||||||
|
cashiername: filter.cashiername,
|
||||||
|
paymentmode: filter.paymentmode,
|
||||||
|
pageno: filter.pageno ?? 0,
|
||||||
|
pagesize,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
details<PosSalesPage>(json) ?? { total: 0, pageno: 0, pagesize, bills: [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bill with its lines. `reference` matches any of three columns server-side
|
||||||
|
* — the terminal's UUID, the invoice number, or the posorderid — so a support
|
||||||
|
* call can start from whichever the caller is looking at.
|
||||||
|
*
|
||||||
|
* Returns null on 404, which is what the server sends when the reference is
|
||||||
|
* real but belongs to a different outlet.
|
||||||
|
*/
|
||||||
|
export async function getPosSaleDetail(
|
||||||
|
locationid: number,
|
||||||
|
reference: string,
|
||||||
|
): Promise<PosBill | null> {
|
||||||
|
try {
|
||||||
|
const json = await posGet('sales/detail', { locationid, reference });
|
||||||
|
return details<PosBill>(json);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPosSalesSummary(filter: PosSalesFilter): Promise<PosSalesSummary | null> {
|
||||||
|
const json = await posGet('sales/summary', {
|
||||||
|
locationid: filter.locationid,
|
||||||
|
fromdate: filter.fromdate,
|
||||||
|
todate: filter.todate,
|
||||||
|
terminalid: filter.terminalid,
|
||||||
|
cashiername: filter.cashiername,
|
||||||
|
paymentmode: filter.paymentmode,
|
||||||
|
});
|
||||||
|
return details<PosSalesSummary>(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One product exactly as a till stores it. */
|
||||||
|
export interface PosCatalogueProduct {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
barcode: string;
|
||||||
|
sku: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
mrp?: number;
|
||||||
|
stock: number;
|
||||||
|
unit: string;
|
||||||
|
/** A fraction (0.05), matching how the till holds it. */
|
||||||
|
gst_rate: number;
|
||||||
|
hsn_code?: string;
|
||||||
|
brand?: string;
|
||||||
|
/** False for anything a till must not be able to ring up — chiefly unpriced
|
||||||
|
* products, which would otherwise sell at ₹0. */
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosCatalogueCustomer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
loyalty_points: number;
|
||||||
|
lifetime_spend: number;
|
||||||
|
visit_count: number;
|
||||||
|
last_visit_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosCatalogue {
|
||||||
|
revision: string;
|
||||||
|
is_delta: boolean;
|
||||||
|
has_more: boolean;
|
||||||
|
products: PosCatalogueProduct[];
|
||||||
|
customers: PosCatalogueCustomer[];
|
||||||
|
retired_product_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a till at this store currently sees.
|
||||||
|
*
|
||||||
|
* Answered as a **bare body**, not the `{code, status, details}` envelope the
|
||||||
|
* sales reads use — it is a terminal-facing endpoint, so it is parsed directly.
|
||||||
|
*
|
||||||
|
* Always called without `since`, which yields a full snapshot. A delta would be
|
||||||
|
* cheaper but describes only what changed, and this panel is asking what the
|
||||||
|
* till can sell *right now*.
|
||||||
|
*/
|
||||||
|
export async function getPosCatalogue(storeId: number, pageSize = 500): Promise<PosCatalogue> {
|
||||||
|
const json = await posGet<PosCatalogue>('catalogue', { store_id: storeId, page_size: pageSize });
|
||||||
|
return {
|
||||||
|
revision: json?.revision ?? '',
|
||||||
|
is_delta: Boolean(json?.is_delta),
|
||||||
|
has_more: Boolean(json?.has_more),
|
||||||
|
products: json?.products ?? [],
|
||||||
|
customers: json?.customers ?? [],
|
||||||
|
retired_product_ids: json?.retired_product_ids ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPosLocationHealth(locationid: number): Promise<PosLocationHealth> {
|
||||||
|
const json = await posGet('health/location', { location_id: locationid });
|
||||||
|
return (
|
||||||
|
details<PosLocationHealth>(json) ?? {
|
||||||
|
location_id: String(locationid),
|
||||||
|
total: 0,
|
||||||
|
online: 0,
|
||||||
|
terminals: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsing the health hash ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four states, not two.
|
||||||
|
*
|
||||||
|
* The heartbeat is every 30s under a 90s TTL, so between those a live till has
|
||||||
|
* no fresh reading and is *not* dead — showing two states would make healthy
|
||||||
|
* tills blink red. And a key can exist while saying "offline", because the
|
||||||
|
* broker's Last Will overwrites the status when a till loses power mid-shift.
|
||||||
|
* That is the only way to tell *closed for the night* from *unplugged*, so it
|
||||||
|
* is kept distinct from a key that simply expired.
|
||||||
|
*/
|
||||||
|
export type TerminalState = 'online' | 'stale' | 'offline_declared' | 'offline_vanished';
|
||||||
|
|
||||||
|
export interface PosTerminal {
|
||||||
|
terminalId: string;
|
||||||
|
locationId: string;
|
||||||
|
storeName: string;
|
||||||
|
appVersion: string;
|
||||||
|
state: TerminalState;
|
||||||
|
/** Server-supplied explanation on the expired-key stub. */
|
||||||
|
reason: string;
|
||||||
|
pendingBills: number;
|
||||||
|
pendingRegistrations: number;
|
||||||
|
oldestPendingAt: string;
|
||||||
|
todayBills: number;
|
||||||
|
todayAmount: number;
|
||||||
|
lastBillAt: string;
|
||||||
|
reportedAt: string;
|
||||||
|
receivedAt: string;
|
||||||
|
/** Seconds since the *server* stamped the reading. Deliberately measured from
|
||||||
|
* `received_at`, never `reported_at` — a till with a wrong clock must not be
|
||||||
|
* able to make itself look fresh. */
|
||||||
|
ageSeconds: number | null;
|
||||||
|
/** `reported_at` minus `received_at`, in seconds. A large value means the
|
||||||
|
* till's clock is wrong, which matters because bills are filed under the
|
||||||
|
* business date the till decided. */
|
||||||
|
clockDriftSeconds: number | null;
|
||||||
|
/** Absent when the till does not collect them — never coerced to zero, or a
|
||||||
|
* board would show every terminal on a flat battery. */
|
||||||
|
batteryLevel: number | null;
|
||||||
|
batteryCharging: boolean | null;
|
||||||
|
storageFreeMb: number | null;
|
||||||
|
printerReachable: boolean | null;
|
||||||
|
drawerStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = (h: PosTerminalRaw, k: string): string => (h[k] ?? '').trim();
|
||||||
|
|
||||||
|
function optNum(h: PosTerminalRaw, k: string): number | null {
|
||||||
|
const raw = s(h, k);
|
||||||
|
if (raw === '') return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redis stores a Go bool as "1"/"0"; tolerate "true"/"false" too. */
|
||||||
|
function optBool(h: PosTerminalRaw, k: string): boolean | null {
|
||||||
|
const raw = s(h, k).toLowerCase();
|
||||||
|
if (raw === '') return null;
|
||||||
|
return raw === '1' || raw === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsSince(iso: string, now: number): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
return Number.isFinite(t) ? Math.round((now - t) / 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTerminal(h: PosTerminalRaw, now: number = Date.now()): PosTerminal {
|
||||||
|
const status = s(h, 'status').toLowerCase();
|
||||||
|
const reason = s(h, 'reason');
|
||||||
|
const receivedAt = s(h, 'received_at');
|
||||||
|
const reportedAt = s(h, 'reported_at');
|
||||||
|
const ageSeconds = secondsSince(receivedAt, now);
|
||||||
|
|
||||||
|
// The expired-key stub is exactly {terminal_id, location_id, status, reason}
|
||||||
|
// — it carries no heartbeat fields at all, which is how it's told apart from
|
||||||
|
// a till that is present and declaring itself offline.
|
||||||
|
const isStub = status === 'offline' && reason !== '' && receivedAt === '';
|
||||||
|
|
||||||
|
let state: TerminalState;
|
||||||
|
if (isStub) state = 'offline_vanished';
|
||||||
|
else if (status !== 'online') state = 'offline_declared';
|
||||||
|
else if (ageSeconds == null || ageSeconds <= 40) state = 'online';
|
||||||
|
else state = 'stale';
|
||||||
|
|
||||||
|
const reportedT = reportedAt ? Date.parse(reportedAt) : NaN;
|
||||||
|
const receivedT = receivedAt ? Date.parse(receivedAt) : NaN;
|
||||||
|
|
||||||
|
return {
|
||||||
|
terminalId: s(h, 'terminal_id'),
|
||||||
|
locationId: s(h, 'location_id'),
|
||||||
|
storeName: s(h, 'store_name'),
|
||||||
|
appVersion: s(h, 'app_version'),
|
||||||
|
state,
|
||||||
|
reason,
|
||||||
|
pendingBills: optNum(h, 'pending_bills') ?? 0,
|
||||||
|
pendingRegistrations: optNum(h, 'pending_registrations') ?? 0,
|
||||||
|
oldestPendingAt: s(h, 'oldest_pending_at'),
|
||||||
|
todayBills: optNum(h, 'today_bills') ?? 0,
|
||||||
|
todayAmount: optNum(h, 'today_amount') ?? 0,
|
||||||
|
lastBillAt: s(h, 'last_bill_at'),
|
||||||
|
reportedAt,
|
||||||
|
receivedAt,
|
||||||
|
ageSeconds,
|
||||||
|
clockDriftSeconds:
|
||||||
|
Number.isFinite(reportedT) && Number.isFinite(receivedT)
|
||||||
|
? Math.round((reportedT - receivedT) / 1000)
|
||||||
|
: null,
|
||||||
|
batteryLevel: optNum(h, 'battery_level'),
|
||||||
|
batteryCharging: optBool(h, 'battery_charging'),
|
||||||
|
storageFreeMb: optNum(h, 'storage_free_mb'),
|
||||||
|
printerReachable: optBool(h, 'printer_reachable'),
|
||||||
|
drawerStatus: s(h, 'drawer_status') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TERMINAL_STATE_LABEL: Record<TerminalState, string> = {
|
||||||
|
online: 'Online',
|
||||||
|
stale: 'Stale',
|
||||||
|
offline_declared: 'Offline',
|
||||||
|
offline_vanished: 'No heartbeat',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TERMINAL_STATE_COLOR: Record<TerminalState, string> = {
|
||||||
|
online: '#10b981',
|
||||||
|
stale: '#f59e0b',
|
||||||
|
offline_declared: '#ef4444',
|
||||||
|
offline_vanished: '#94a3b8',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Merging a fan-out ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PosOutletSummary {
|
||||||
|
locationid: number;
|
||||||
|
name: string;
|
||||||
|
summary: PosSalesSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold per-outlet summaries into one.
|
||||||
|
*
|
||||||
|
* `averagebill` is **recomputed** as `Σtotal / Σbills`, never averaged. Taking
|
||||||
|
* the mean of twelve outlets' averages weights a four-bill kiosk the same as a
|
||||||
|
* nine-hundred-bill store, which is not a number anyone should act on.
|
||||||
|
*/
|
||||||
|
export function mergeSummaries(parts: Array<PosSalesSummary | null>): PosSalesSummary {
|
||||||
|
const merged: PosSalesSummary = {
|
||||||
|
locationid: 0,
|
||||||
|
fromdate: '',
|
||||||
|
todate: '',
|
||||||
|
billcount: 0,
|
||||||
|
itemcount: 0,
|
||||||
|
grosssales: 0,
|
||||||
|
taxcollected: 0,
|
||||||
|
discountgiven: 0,
|
||||||
|
roundoff: 0,
|
||||||
|
averagebill: 0,
|
||||||
|
bypaymentmode: [],
|
||||||
|
byday: [],
|
||||||
|
byterminal: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const byMode = new Map<string, PosPaymentTotal>();
|
||||||
|
const byDay = new Map<string, PosDayTotal>();
|
||||||
|
const byTerm = new Map<string, PosTerminalTotal>();
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!p) continue;
|
||||||
|
merged.billcount += p.billcount || 0;
|
||||||
|
merged.itemcount += p.itemcount || 0;
|
||||||
|
merged.grosssales += p.grosssales || 0;
|
||||||
|
merged.taxcollected += p.taxcollected || 0;
|
||||||
|
merged.discountgiven += p.discountgiven || 0;
|
||||||
|
merged.roundoff += p.roundoff || 0;
|
||||||
|
merged.fromdate ||= p.fromdate;
|
||||||
|
merged.todate ||= p.todate;
|
||||||
|
|
||||||
|
for (const m of p.bypaymentmode ?? []) {
|
||||||
|
const key = m.paymentmode || '';
|
||||||
|
const at = byMode.get(key) ?? { paymentmode: key, billcount: 0, amount: 0 };
|
||||||
|
at.billcount += m.billcount || 0;
|
||||||
|
at.amount += m.amount || 0;
|
||||||
|
byMode.set(key, at);
|
||||||
|
}
|
||||||
|
for (const d of p.byday ?? []) {
|
||||||
|
const at = byDay.get(d.businessdate) ?? { businessdate: d.businessdate, billcount: 0, amount: 0 };
|
||||||
|
at.billcount += d.billcount || 0;
|
||||||
|
at.amount += d.amount || 0;
|
||||||
|
byDay.set(d.businessdate, at);
|
||||||
|
}
|
||||||
|
for (const t of p.byterminal ?? []) {
|
||||||
|
const key = t.terminalid || '';
|
||||||
|
const at = byTerm.get(key) ?? { terminalid: key, billcount: 0, amount: 0 };
|
||||||
|
at.billcount += t.billcount || 0;
|
||||||
|
at.amount += t.amount || 0;
|
||||||
|
byTerm.set(key, at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.averagebill = merged.billcount > 0 ? merged.grosssales / merged.billcount : 0;
|
||||||
|
merged.bypaymentmode = [...byMode.values()].sort((a, b) => b.amount - a.amount);
|
||||||
|
merged.byday = [...byDay.values()].sort((a, b) => a.businessdate.localeCompare(b.businessdate));
|
||||||
|
merged.byterminal = [...byTerm.values()].sort((a, b) => b.amount - a.amount);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average bill for one outlet, recomputed rather than trusted — same trap as
|
||||||
|
* the merge above, and the store leaderboard is where it would bite. */
|
||||||
|
export const averageBill = (sm: PosSalesSummary | null): number =>
|
||||||
|
sm && sm.billcount > 0 ? sm.grosssales / sm.billcount : 0;
|
||||||
|
|
||||||
|
// ── Bill helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PosPaymentSplit { method: string; amount: number; reference?: string }
|
||||||
|
export interface PosPromo { id?: string; name?: string; type?: string; amount?: number }
|
||||||
|
|
||||||
|
function parseJsonField<T>(raw: string | undefined, fallback: T): T {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return (v ?? fallback) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full tender split. A bill can be part cash, part card, part loyalty, and
|
||||||
|
* `paymentmode` only names the largest of them. */
|
||||||
|
export const billPayments = (b: PosBill): PosPaymentSplit[] =>
|
||||||
|
parseJsonField<PosPaymentSplit[]>(b.paymentsjson, []);
|
||||||
|
|
||||||
|
/** GST per slab, as printed on the tax invoice. Keys are rate fractions
|
||||||
|
* ("0.05"), values are the tax amount at that slab. */
|
||||||
|
export const billTaxBreakdown = (b: PosBill): Array<{ rate: number; amount: number }> => {
|
||||||
|
const raw = parseJsonField<Record<string, number>>(b.taxbreakdownjson, {});
|
||||||
|
return Object.entries(raw)
|
||||||
|
.map(([rate, amount]) => ({ rate: Number(rate), amount: Number(amount) }))
|
||||||
|
.filter((r) => Number.isFinite(r.rate) && Number.isFinite(r.amount))
|
||||||
|
// Tills send a `{"0.0": 0}` placeholder on zero-rated bills. Rendering it
|
||||||
|
// as a "0.00% — ₹0.00" slab reads like a real tax line for an untaxed sale.
|
||||||
|
.filter((r) => r.amount !== 0 || r.rate !== 0)
|
||||||
|
.sort((a, b2) => a.rate - b2.rate);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Who the bill was rung for. A till can attach a customer id without a name or
|
||||||
|
* mobile, so "Walk-in" is reserved for bills with no customer at all. */
|
||||||
|
export function billCustomer(b: PosBill): string {
|
||||||
|
const named = (b.customername || '').trim() || (b.customermobile || '').trim();
|
||||||
|
if (named) return named;
|
||||||
|
return b.customerid > 0 ? `Customer #${b.customerid}` : 'Walk-in';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const billPromos = (b: PosBill): PosPromo[] => parseJsonField<PosPromo[]>(b.promosjson, []);
|
||||||
|
|
||||||
|
export type SyncLag =
|
||||||
|
| { kind: 'legacy'; seconds: number }
|
||||||
|
| { kind: 'ok'; seconds: number }
|
||||||
|
| { kind: 'delayed'; seconds: number }
|
||||||
|
| { kind: 'unknown' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a bill took to reach us.
|
||||||
|
*
|
||||||
|
* A negative value is **not** a fast upload — it is a bill written before the
|
||||||
|
* till started sending a UTC offset, whose `billedat` holds IST wall-clock
|
||||||
|
* stamped as though it were UTC. Those come back exactly 5h30m "early". They
|
||||||
|
* are classed `legacy` rather than alerted on, because flooring the panel at a
|
||||||
|
* date would still leak the ones rung on the morning of the fix.
|
||||||
|
*
|
||||||
|
* `businessdate` is unaffected by that skew (storing local wall-clock as UTC
|
||||||
|
* preserves the date), so day bucketing and the date filters stay correct — the
|
||||||
|
* damage is confined to this one comparison.
|
||||||
|
*/
|
||||||
|
export function syncLag(b: PosBill): SyncLag {
|
||||||
|
const billed = Date.parse(b.billedat);
|
||||||
|
const received = Date.parse(b.receivedat);
|
||||||
|
if (!Number.isFinite(billed) || !Number.isFinite(received)) return { kind: 'unknown' };
|
||||||
|
const seconds = Math.round((received - billed) / 1000);
|
||||||
|
if (seconds < 0) return { kind: 'legacy', seconds };
|
||||||
|
if (seconds > 900) return { kind: 'delayed', seconds };
|
||||||
|
return { kind: 'ok', seconds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal ids are blank on bills ingested before the fallback landed. Shown
|
||||||
|
* as a named bucket rather than an empty cell so the column reads honestly. */
|
||||||
|
export const terminalLabel = (t: string): string => (t && t.trim()) || 'Unassigned';
|
||||||
|
|
||||||
|
export const todayISO = (): string => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const daysAgoISO = (days: number): string => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inr = (n: number): string =>
|
||||||
|
`₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
export const inrCompact = (n: number): string =>
|
||||||
|
`₹${(Number.isFinite(n) ? n : 0).toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;
|
||||||
|
|
||||||
|
/** Duration in the units a person reading a sync board actually wants. */
|
||||||
|
export function humanDuration(seconds: number): string {
|
||||||
|
const abs = Math.abs(seconds);
|
||||||
|
if (abs < 60) return `${abs}s`;
|
||||||
|
if (abs < 3600) return `${Math.round(abs / 60)}m`;
|
||||||
|
if (abs < 86400) return `${(abs / 3600).toFixed(1)}h`;
|
||||||
|
return `${(abs / 86400).toFixed(1)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2026-08-05T17:29:00Z` → `17:29`. Rendered as sent; no timezone maths, since
|
||||||
|
* the stored value's offset is not trustworthy across the fix boundary. */
|
||||||
|
export function billTime(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const m = iso.match(/T(\d{2}:\d{2})/);
|
||||||
|
return m ? m[1] : '—';
|
||||||
|
}
|
||||||
176
src/services/posQueries.ts
Normal file
176
src/services/posQueries.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TanStack Query hooks over the POS client in `./posApi`.
|
||||||
|
*
|
||||||
|
* The tenant-wide hooks fan out — one request per outlet, six in flight — and
|
||||||
|
* merge here, because the POS API has no tenant parameter. That is deliberate
|
||||||
|
* for now: the largest tenant on the platform has 22 outlets, so the worst case
|
||||||
|
* is 44 requests per refresh, which is comfortable. A `GetTenantSalesSummary`
|
||||||
|
* on the backend would collapse it to one, and is worth adding once there is a
|
||||||
|
* measurement that justifies it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getPosLocationHealth,
|
||||||
|
getPosSales,
|
||||||
|
getPosSaleDetail,
|
||||||
|
getPosCatalogue,
|
||||||
|
getPosSalesSummary,
|
||||||
|
mapLimit,
|
||||||
|
mergeSummaries,
|
||||||
|
parseTerminal,
|
||||||
|
type PosBill,
|
||||||
|
type PosCatalogue,
|
||||||
|
type PosSalesFilter,
|
||||||
|
type PosSalesPage,
|
||||||
|
type PosSalesSummary,
|
||||||
|
type PosTerminal,
|
||||||
|
} from './posApi';
|
||||||
|
|
||||||
|
/** Heartbeats land every 30s, so polling faster only burns requests. */
|
||||||
|
const HEALTH_POLL_MS = 30_000;
|
||||||
|
const FANOUT_LIMIT = 6;
|
||||||
|
|
||||||
|
export const posKeys = {
|
||||||
|
all: ['pos'] as const,
|
||||||
|
health: (ids: number[]) => [...posKeys.all, 'health', ids.join(',')] as const,
|
||||||
|
summary: (ids: number[], from: string, to: string) =>
|
||||||
|
[...posKeys.all, 'summary', ids.join(','), from, to] as const,
|
||||||
|
sales: (f: PosSalesFilter) =>
|
||||||
|
[
|
||||||
|
...posKeys.all,
|
||||||
|
'sales',
|
||||||
|
f.locationid,
|
||||||
|
f.fromdate ?? '',
|
||||||
|
f.todate ?? '',
|
||||||
|
f.terminalid ?? '',
|
||||||
|
f.cashiername ?? '',
|
||||||
|
f.paymentmode ?? '',
|
||||||
|
f.pageno ?? 0,
|
||||||
|
f.pagesize ?? 50,
|
||||||
|
] as const,
|
||||||
|
catalogue: (locationid: number) => [...posKeys.all, 'catalogue', locationid] as const,
|
||||||
|
detail: (locationid: number, reference: string) =>
|
||||||
|
[...posKeys.all, 'detail', locationid, reference] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One outlet's tills, with the outlet id kept alongside so a tenant board can
|
||||||
|
* attribute a terminal to a store without a second lookup. */
|
||||||
|
export interface PosOutletHealth {
|
||||||
|
locationid: number;
|
||||||
|
total: number;
|
||||||
|
online: number;
|
||||||
|
terminals: PosTerminal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal health across one or more outlets.
|
||||||
|
*
|
||||||
|
* Parsing happens in the query function rather than the component so the Redis
|
||||||
|
* strings are converted exactly once per fetch, not on every render.
|
||||||
|
*/
|
||||||
|
export function usePosFleetHealth(locationIds: number[]) {
|
||||||
|
const ids = [...new Set(locationIds.filter((n) => n > 0))].sort((a, b) => a - b);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: posKeys.health(ids),
|
||||||
|
enabled: ids.length > 0,
|
||||||
|
refetchInterval: HEALTH_POLL_MS,
|
||||||
|
queryFn: async (): Promise<PosOutletHealth[]> => {
|
||||||
|
const now = Date.now();
|
||||||
|
return mapLimit(ids, FANOUT_LIMIT, async (locationid) => {
|
||||||
|
try {
|
||||||
|
const raw = await getPosLocationHealth(locationid);
|
||||||
|
const terminals = (raw.terminals ?? []).map((t) => parseTerminal(t, now));
|
||||||
|
// `online` is recounted here rather than trusted: the server counts
|
||||||
|
// status === "online", which cannot distinguish a fresh heartbeat
|
||||||
|
// from one that is about to expire.
|
||||||
|
return {
|
||||||
|
locationid,
|
||||||
|
total: terminals.length,
|
||||||
|
online: terminals.filter((t) => t.state === 'online').length,
|
||||||
|
terminals,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// One outlet's Redis being unreachable must not blank the board for
|
||||||
|
// every other shop.
|
||||||
|
return { locationid, total: 0, online: 0, terminals: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosScopedSummary {
|
||||||
|
/** Per-outlet, for a leaderboard. Null where the outlet has no POS data. */
|
||||||
|
perOutlet: Array<{ locationid: number; summary: PosSalesSummary | null }>;
|
||||||
|
/** All outlets folded together, with `averagebill` recomputed. */
|
||||||
|
merged: PosSalesSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales totals for a date range across one or more outlets.
|
||||||
|
*
|
||||||
|
* The merge recomputes `averagebill` from the summed totals — see
|
||||||
|
* `mergeSummaries`.
|
||||||
|
*/
|
||||||
|
export function usePosSalesSummary(locationIds: number[], fromdate: string, todate: string) {
|
||||||
|
const ids = [...new Set(locationIds.filter((n) => n > 0))].sort((a, b) => a - b);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: posKeys.summary(ids, fromdate, todate),
|
||||||
|
enabled: ids.length > 0 && Boolean(fromdate) && Boolean(todate),
|
||||||
|
queryFn: async (): Promise<PosScopedSummary> => {
|
||||||
|
const perOutlet = await mapLimit(ids, FANOUT_LIMIT, async (locationid) => {
|
||||||
|
try {
|
||||||
|
return { locationid, summary: await getPosSalesSummary({ locationid, fromdate, todate }) };
|
||||||
|
} catch {
|
||||||
|
return { locationid, summary: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { perOutlet, merged: mergeSummaries(perOutlet.map((p) => p.summary)) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page of bills for one outlet. Bills are always single-outlet: `locationid`
|
||||||
|
* is the authorisation boundary and the API takes exactly one. */
|
||||||
|
export function usePosSales(filter: PosSalesFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: posKeys.sales(filter),
|
||||||
|
enabled: filter.locationid > 0,
|
||||||
|
queryFn: (): Promise<PosSalesPage> => getPosSales(filter),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One bill with its lines. Resolves to null when the reference does not belong
|
||||||
|
* to this outlet — the server answers 404 for that case on purpose. */
|
||||||
|
export function usePosSaleDetail(locationid: number, reference: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: posKeys.detail(locationid, reference),
|
||||||
|
enabled: locationid > 0 && reference.trim().length > 0,
|
||||||
|
queryFn: (): Promise<PosBill | null> => getPosSaleDetail(locationid, reference),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product snapshot a till at this store is working from.
|
||||||
|
*
|
||||||
|
* Deliberately **not** fanned out across the tenant and gated on `enabled`, so
|
||||||
|
* it is fetched for one outlet only and only once its panel is opened. A
|
||||||
|
* snapshot is the whole shelf, and pulling one per outlet on every page load
|
||||||
|
* would be the heaviest thing on the page for a diagnostic most visits never
|
||||||
|
* look at.
|
||||||
|
*/
|
||||||
|
export function usePosCatalogue(locationid: number, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: posKeys.catalogue(locationid),
|
||||||
|
enabled: enabled && locationid > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: (): Promise<PosCatalogue> => getPosCatalogue(locationid),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch' | 'catalogue';
|
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch' | 'catalogue' | 'pos';
|
||||||
|
|
||||||
export interface KPICardData {
|
export interface KPICardData {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user