@@ -107,8 +116,32 @@ export function App() {
} />
- {/* Not built yet. Named rather than missing, so the redirect has somewhere to land. */}
- } />
+ {/* Store user — one branch, fixed. Same four destinations as the
+ merchant, and mostly the same pages: what differs is the scope, not
+ the shape, so pinning them beats a second set that drifts. */}
+
+ }>
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ {/* Reached from the account menu. A shop does not commission outlets,
+ so there is deliberately no `branches/new` here. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+
);
}
-
-function NotBuiltYet({ workspace }: { workspace: string }) {
- const { signOut } = useAuth();
- return (
-
-
-
- The {workspace} workspace is not built yet
-
-
- This console currently covers the Nearle Admin workspace. Your account signs in
- correctly — there is simply nothing here for it to open.
-
-
-
-
- );
-}
diff --git a/src/api/catalogue.ts b/src/api/catalogue.ts
index 5826383..7657422 100644
--- a/src/api/catalogue.ts
+++ b/src/api/catalogue.ts
@@ -36,7 +36,19 @@ export const catalogueApi = {
/** Brands with product counts, for the filter chip row. Never hardcode this list. */
brands: () => api.get(`${WEB}/catalogue/getbrands`),
- categories: () => api.get(`${WEB}/catalogue/getcategories`),
+ /** Requires a brand — the backend reads categories from one brand's table. */
+ categories: (brand: string) =>
+ api.get(`${WEB}/catalogue/getcategories`, { brand }),
+
+ /**
+ * One catalogue row in full — the fields the import leaves behind
+ * (highlights, nutrients, FSSAI, every image, provider list).
+ *
+ * Returns nothing when a re-scrape has retired the source row, which is
+ * common: the tenant's product is a snapshot and outlives its origin.
+ */
+ product: (brand: string, sku: string) =>
+ api.get(`${WEB}/catalogue/getproduct`, { brand, sku }),
/**
* The `(brand, catalogueid)` pairs this tenant has already imported, for
diff --git a/src/api/customers.ts b/src/api/customers.ts
new file mode 100644
index 0000000..049c2d1
--- /dev/null
+++ b/src/api/customers.ts
@@ -0,0 +1,74 @@
+import { api, WEB } from './client';
+
+/**
+ * Customers, as one branch sees them.
+ *
+ * `gettenantcustomers` genuinely branches on `locationid`: with one it INNER
+ * JOINs `tenantcustomers` and returns only the people registered against that
+ * outlet; without one it returns the tenant's whole book. So this is one of the
+ * few reads where the branch scope is honoured server-side rather than by us.
+ *
+ * The pagination is the trap. The controller supplies NO defaults — a missing
+ * `pageno`/`pagesize` becomes `LIMIT 0 OFFSET 0`, which returns an empty list
+ * rather than an error, and reads on screen as "this shop has no customers".
+ * Both are therefore always sent from here, never left to the caller.
+ */
+export interface CustomerInfo {
+ customerid: number;
+ firstname?: string;
+ lastname?: string;
+ contactno?: string;
+ email?: string;
+ address?: string;
+ suburb?: string;
+ city?: string;
+ state?: string;
+ landmark?: string;
+ doorno?: string;
+ postcode?: string;
+ deliverylocationid?: number;
+ tenantlocationid?: number;
+ applocationid?: number;
+ status?: string;
+}
+
+export interface CustomerQuery {
+ tenantid: number;
+ /** Omit for the tenant's whole book. */
+ locationid?: number;
+ keyword?: string;
+ pageno?: number;
+ pagesize?: number;
+}
+
+export const customersApi = {
+ list: (query: CustomerQuery) =>
+ api.get(`${WEB}/customers/gettenantcustomers`, {
+ tenantid: query.tenantid,
+ locationid: query.locationid,
+ keyword: query.keyword || undefined,
+ pageno: query.pageno ?? 1,
+ pagesize: query.pagesize ?? 100,
+ }),
+};
+
+/** A display name that never renders as an empty string. */
+export function customerName(customer: CustomerInfo): string {
+ const name = [customer.firstname, customer.lastname].filter(Boolean).join(' ').trim();
+ return name || customer.contactno || `Customer ${customer.customerid}`;
+}
+
+/**
+ * Where they are, in the shortest true form.
+ *
+ * Door numbers are dropped: "12B" tells a shopkeeper nothing, and the old
+ * console's fallback of "Coimbatore" for anyone without an address invented a
+ * locality for every record that had none.
+ */
+export function customerLocality(customer: CustomerInfo): string {
+ const parts = [customer.suburb, customer.city].filter(Boolean) as string[];
+ if (parts.length > 0) return parts.join(', ');
+ const address = (customer.address ?? '').split(',').map((part) => part.trim());
+ const meaningful = address.find((part) => part.length > 3 && !/^\d/.test(part));
+ return meaningful ?? '—';
+}
diff --git a/src/api/offlineSales.ts b/src/api/offlineSales.ts
new file mode 100644
index 0000000..962a45e
--- /dev/null
+++ b/src/api/offlineSales.ts
@@ -0,0 +1,114 @@
+import { api, WEB } from './client';
+
+/**
+ * Counter sales, imported from a spreadsheet.
+ *
+ * A sale rung up at a till that has no POS terminal never passes through the
+ * app, so nothing deducts its stock and nothing counts its revenue. This is the
+ * one path that carries those sales into the same order table an app order
+ * lands in, which is what keeps the stock ledger a single source of truth.
+ */
+
+/** One line of the downloaded workbook — a product stocked at one branch. */
+export interface SaleTemplateRow {
+ tenantid: number;
+ locationid: number;
+ locationname: string;
+ productid: number;
+ productname: string;
+ productunit?: string;
+ unitvalue?: string;
+ categoryname?: string;
+ currentstock: number;
+ price: number;
+ taxpercent: number;
+}
+
+export interface SaleTemplateLocation {
+ locationid: number;
+ locationname: string;
+ productcount: number;
+}
+
+export interface SaleTemplate {
+ tenantid: number;
+ /** 0 when the workbook spans every branch of the tenant. */
+ locationid: number;
+ locations: SaleTemplateLocation[];
+ products: SaleTemplateRow[];
+}
+
+export interface OfflineSaleItemInput {
+ productid: number;
+ productname?: string;
+ qtysold: number;
+ unitprice?: number;
+ discountamount?: number;
+ taxpercent?: number;
+}
+
+export interface OfflineSaleBillInput {
+ locationid: number;
+ billno: string;
+ saledate?: string;
+ paymentmode?: string;
+ customername?: string;
+ customermobile?: string;
+ remarks?: string;
+ items: OfflineSaleItemInput[];
+}
+
+export interface OfflineSaleResult {
+ locationid: number;
+ locationname: string;
+ billno: string;
+ /** `imported` · `duplicate` · `failed`. */
+ status: string;
+ orderid?: string;
+ orderheaderid?: number;
+ itemcount?: number;
+ amount?: number;
+ message?: string;
+}
+
+export interface OfflineSalesUploadResponse {
+ imported: number;
+ duplicate: number;
+ failed: number;
+ totalamount: number;
+ results: OfflineSaleResult[];
+}
+
+export const offlineSalesApi = {
+ /**
+ * The workbook's contents.
+ *
+ * Deliberately unpaged by the backend — it is a worksheet, and a page of it
+ * would be a worksheet with rows missing. `locationid` 0 spans every branch;
+ * a specific one is validated against the tenant before it answers.
+ */
+ template: (tenantid: number, locationid?: number) =>
+ api.get(`${WEB}/products/getsaletemplate`, {
+ tenantid,
+ locationid: locationid ?? 0,
+ }),
+
+ /**
+ * Import the filled-in workbook.
+ *
+ * `locationid` is a **constraint, not a destination**: left at 0 each bill
+ * goes to the branch its own rows name, and set to a branch every bill naming
+ * a different one is refused. It is the only place in the whole backend that
+ * holds a store user to their own store, so the Store user workspace must
+ * always send it.
+ *
+ * Re-uploading the same file is safe: a bill number already recorded for that
+ * branch comes back as `duplicate` and its stock is not deducted twice.
+ */
+ upload: (body: {
+ tenantid: number;
+ locationid: number;
+ userid: number;
+ bills: OfflineSaleBillInput[];
+ }) => api.post(`${WEB}/orders/uploadofflinesales`, body),
+};
diff --git a/src/api/products.ts b/src/api/products.ts
index 2563660..f49f8b1 100644
--- a/src/api/products.ts
+++ b/src/api/products.ts
@@ -78,8 +78,43 @@ export const productsApi = {
createProductStock: (rows: ProductStockRequest[]) =>
api.post(`${WEB}/products/createproductstock`, rows),
- publish: (body: { tenantid: number; locationid: number; productid: number }) =>
- api.post(`${WEB}/products/publishproduct`, body),
+ /**
+ * Price a product and release it to the shops.
+ *
+ * There is NO `locationid` — deliberately, on the backend's side. It reads
+ * the tenant's active outlets itself, because "a console that sent its own
+ * list could publish to a subset by omission"
+ * (`productPublishRepository.go:50`). One call sets this price at every
+ * branch and also writes `products.retailprice` and `taxpercent`.
+ *
+ * Refuses `price <= 0`.
+ */
+ publish: (body: {
+ tenantid: number;
+ productid: number;
+ price: number;
+ taxpercent: number;
+ }) => api.post(`${WEB}/products/publishproduct`, body),
+
+ /**
+ * Clear `publishedat`. Narrower than it sounds — the till and the customer
+ * app do not filter on this column, so this hides the product from the store
+ * catalogue view and nothing else. See `Product.publishedat`.
+ */
+ unpublish: (body: { tenantid: number; productid: number }) =>
+ api.post(`${WEB}/products/unpublishproduct`, body),
+
+ /**
+ * The tenant's real category list, synthesised from products in use.
+ *
+ * Not `getproductcategories` — that reads a master table missing rows for
+ * categoryids live in production, hardcoded to `moduleid = 2`, unscoped.
+ */
+ tenantCategories: (tenantid: number) =>
+ api.get<{ categoryid: number; categoryname: string }[]>(
+ `${WEB}/products/gettenantcategories`,
+ { tenantid },
+ ),
/** Unlinks from the store. The product row and its order history survive. */
removeFromStore: (body: { tenantid: number; locationid: number; productid: number }) =>
diff --git a/src/api/stock.ts b/src/api/stock.ts
index 5dc0aef..ab9644f 100644
--- a/src/api/stock.ts
+++ b/src/api/stock.ts
@@ -40,7 +40,35 @@ export interface StockRequestQuery {
pagesize?: number;
}
+export interface CreateStockRequest {
+ tenantid: number;
+ locationid: number;
+ productid: number;
+ qty: number;
+ /** Carried so the admin's queue can name the branch without a second read. */
+ locationname?: string;
+ productname?: string;
+}
+
export const stockApi = {
+ /**
+ * A branch asks its admin for stock.
+ *
+ * The only write a Store user has against inventory, and deliberately so:
+ * nothing here moves the ledger. `status` is always Pending — the backend
+ * defaults to it when blank, but sending it makes the intent explicit rather
+ * than relying on a default that a later release could change.
+ *
+ * There is no reason field, no requester and no wanted-by date in
+ * `stockrequests`, so the request carries a product and a quantity and
+ * nothing else. Do not invent the rest in the UI.
+ */
+ create: (body: CreateStockRequest) =>
+ api.post(`${WEB}/products/createstockrequest`, {
+ ...body,
+ status: STOCK_REQUEST_STATUS.pending,
+ }),
+
requests: (query: StockRequestQuery) =>
api.get(`${WEB}/products/getstockrequests`, {
tenantid: query.tenantid,
diff --git a/src/api/tenants.ts b/src/api/tenants.ts
index 095e132..02c631f 100644
--- a/src/api/tenants.ts
+++ b/src/api/tenants.ts
@@ -20,6 +20,8 @@ export interface CreateTenantRequest {
latitude?: string;
longitude?: string;
moduleid?: number;
+ /** The city this merchant trades in. `app_location`, not a branch. */
+ applocationid?: number;
status?: string;
}
@@ -43,13 +45,45 @@ export interface CreateBranchRequest {
status?: string;
}
+export interface TenantListQuery {
+ pageno?: number;
+ pagesize?: number;
+ /** `Active` / `InActive`. Omitted, the backend returns every state. */
+ status?: string;
+ applocationid?: number;
+ tenanttype?: string;
+ keyword?: string;
+}
+
export const tenantsApi = {
/**
* Every tenant on the platform. Deliberately unscoped — this is the
* Nearle Admin's list, and the backend treats it as the platform-operator
* endpoint rather than a tenant-scoped one.
*/
- listAll: () => api.get(`${WEB}/tenants/getalltenants`),
+ listAll: (query: TenantListQuery = {}) =>
+ api.get(`${WEB}/tenants/getalltenants`, {
+ pageno: query.pageno ?? 1,
+ pagesize: query.pagesize ?? 100,
+ status: query.status,
+ applocationid: query.applocationid,
+ tenanttype: query.tenanttype,
+ keyword: query.keyword,
+ }),
+
+ /**
+ * Tenants by approval state — the only way to see the ones awaiting it.
+ *
+ * `status=pending` is not a status at all: the handler branches on the word
+ * and queries `approved = 0` instead (`tenantRepository.go:45-77`). Anything
+ * else means `approved = 1 AND status = ?`. So an unapproved merchant is
+ * invisible to every other endpoint, including `getalltenants`.
+ *
+ * Nothing can approve one over HTTP. `approved` is writable only at creation,
+ * so this list is a queue to work from, not one to act on.
+ */
+ byApproval: (status: 'pending' | 'Active' | 'InActive', keyword?: string) =>
+ api.get(`${WEB}/tenants/search`, { status, keyword }),
/** Branches under one tenant. `tenantid` is required — omit it and it 400s. */
locations: (tenantid: number) =>
@@ -58,14 +92,100 @@ export const tenantsApi = {
search: (keyword: string) =>
api.get(`${WEB}/tenants/searchbykeyword`, { keyword }),
- /** Provisions the enterprise and spawns its primary Administrator account. */
+ /**
+ * Provisions the enterprise, its first outlet, and the primary Administrator
+ * account — one transaction writing `tenants`, `ordersequences`, `app_users`
+ * (roleid 1, configid forced to 1), `customers`, `customerlocations` and
+ * `tenantcustomers`.
+ *
+ * `createtenantuser`, NOT `createtenantlocation`. The latter takes a
+ * `Tenantlocations` and writes a BRANCH under a tenant that already exists —
+ * pointing the merchant form at it created an outlet and no merchant.
+ *
+ * The primary outlet is NESTED. The backend reads it off
+ * `Tenants.Tenantlocations` and creates it in the same transaction, so a
+ * tenant can never exist without somewhere to trade from.
+ */
createTenant: (body: CreateTenantRequest) =>
- api.post(`${WEB}/tenants/createtenantlocation`, body),
+ api.post(`${WEB}/tenants/createtenantuser`, toTenantBody(body)),
- /** Commissions a branch and spawns a placeholder branch-manager account. */
+ /**
+ * Commissions a branch and spawns its login (roleid 0, empty password).
+ *
+ * `createtenantlocation`, not `createlocation`: only this one returns the
+ * created row, and the new `locationid` is what a QR code and every
+ * follow-up write need. `createlocation` answers 201 with a message and no
+ * `details` at all.
+ */
createBranch: (body: CreateBranchRequest) =>
- api.post(`${WEB}/tenants/createlocation`, body),
+ api.post(`${WEB}/tenants/createtenantlocation`, body),
updateBranch: (body: Partial & { locationid: number }) =>
api.put(`${WEB}/tenants/updatelocation`, body),
};
+
+/**
+ * The merchant form, in the shape `models.Tenants` expects.
+ *
+ * `configid` and `applocationid` are sent because the account this call spawns
+ * is looked up by `configid` at every sign-in, and `applocationid` is the city
+ * the tenant trades in. `approved: 1` and `status: 'Active'` are set here
+ * because they can only ever be set here — there is no update or approve
+ * endpoint, so a tenant created unapproved stays unapproved forever.
+ */
+function toTenantBody(form: CreateTenantRequest): Record {
+ return {
+ tenantname: form.tenantname,
+ companyname: form.companyname,
+ primaryemail: form.primaryemail,
+ primarycontact: form.primarycontact,
+ categoryid: form.categoryid,
+ subcategoryid: form.subcategoryid ?? 0,
+ address: form.address,
+ suburb: form.suburb ?? '',
+ city: form.city,
+ state: form.state,
+ postcode: form.postcode,
+ latitude: form.latitude ?? '',
+ longitude: form.longitude ?? '',
+ configid: 1,
+ moduleid: form.moduleid ?? 2,
+ applocationid: form.applocationid ?? 1,
+ approved: 1,
+ status: form.status ?? 'Active',
+ // The primary outlet, created in the same transaction. Its address
+ // defaults to the tenant's — a merchant's first shop is at the address
+ // they just typed far more often than not, and it can be edited after.
+ tenantlocations: {
+ locationname: form.locationname,
+ email: form.primaryemail,
+ contactno: form.primarycontact,
+ address: form.address,
+ suburb: form.suburb ?? '',
+ city: form.city,
+ state: form.state,
+ postcode: form.postcode,
+ latitude: form.latitude ?? '',
+ longitude: form.longitude ?? '',
+ applocationid: form.applocationid ?? 1,
+ status: 'Active',
+ },
+ };
+}
+
+/** One row of the `app_category` master — the business categories a tenant picks from. */
+export interface AppCategory {
+ categoryid: number;
+ categoryname: string;
+}
+
+export const utilsApi = {
+ /**
+ * The business-category master.
+ *
+ * Read rather than hardcoded. The old console typed four values into the
+ * form and never called this, which means a category added to the master is
+ * invisible to onboarding until someone edits the frontend.
+ */
+ appCategories: () => api.get(`${WEB}/utils/getappcategories`),
+};
diff --git a/src/api/types.ts b/src/api/types.ts
index 7399c66..3093025 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -201,6 +201,16 @@ export interface Product {
retailprice?: number;
productstatus?: string;
locationstatus?: string;
+ /**
+ * When this product was released to the shops. NULL while it sits in the
+ * admin catalogue awaiting a price.
+ *
+ * Selected only by `getlocationproducts`, and NOTHING in the backend filters
+ * on it — the till gates on price and status instead
+ * (`posRepository.go:735`). So unpublishing does not withdraw a product from
+ * the POS or the customer app, only from this console's catalogue view.
+ */
+ publishedat?: string | null;
}
export interface ProductCategory {
diff --git a/src/auth/roles.ts b/src/auth/roles.ts
index 11dcd4c..096d94f 100644
--- a/src/auth/roles.ts
+++ b/src/auth/roles.ts
@@ -84,5 +84,8 @@ export const HOME_ROUTE: Record = {
export const ROLE_LABEL: Record = {
'nearle-admin': 'Nearle Admin',
'store-admin': 'Store Admin',
- 'store-manager': 'Store Manager',
+ // The backend's own word for it: an `app_users` row with roleid 0, bound to
+ // one `tenantlocations.locationid`. "Manager" implied a rank the record does
+ // not carry.
+ 'store-manager': 'Store user',
};
diff --git a/src/auth/session.ts b/src/auth/session.ts
index 4c7e033..6616202 100644
--- a/src/auth/session.ts
+++ b/src/auth/session.ts
@@ -26,31 +26,50 @@ export class PasswordSetupRequiredError extends Error {
interface LoginBody {
authname: string;
password: string;
- roleid?: number;
- configid?: number;
+ /**
+ * Not optional in practice.
+ *
+ * The lookup behind every login is `WHERE authname = ? AND configid = ?`
+ * (`userRepository.go:218`). Omitted, Go receives 0, the query matches no
+ * row, and every account on the platform answers "Invalid Email". The console
+ * surface is configid 1 — `createtenantuser` hard-codes it into the account it
+ * spawns for exactly this reason.
+ */
+ configid: number;
}
+/** The console's surface. Every account this app can sign in carries it. */
+const CONFIG_ID = 1;
+
/**
- * Signs in against the web login endpoint.
+ * Signs in.
*
- * The handler answers HTTP 200 with `status: false` for a wrong password, and
- * with `code: 409` plus `details.setup` when no password has been set, so the
- * envelope is inspected rather than trusting the HTTP status.
+ * `applogin`, not `tenant/weblogin`. The latter carries a check the former does
+ * not — `request.roleid == app_users.roleid` (`userService.go:224`) — and since
+ * Go's zero value is 0, a request without a roleid means "roleid must be 0".
+ * That is the branch-user role, so weblogin silently locked out every Store
+ * Admin and every platform operator with a 403 reading "Unauthorized email".
+ *
+ * The handler answers HTTP 200 with `status: false` for most failures, so the
+ * envelope is inspected rather than the HTTP status.
*/
export async function login(email: string, password: string): Promise {
- const body: LoginBody = { authname: email.trim(), password };
+ const body: LoginBody = { authname: email.trim(), password, configid: CONFIG_ID };
const envelope = await api.envelope(
- `${WEB}/users/tenant/weblogin`,
+ `${WEB}/users/applogin`,
{ method: 'POST', body },
);
+ // A brand-new account — `createtenantlocation` spawns branch logins with an
+ // empty password — answers `status: true` with a 409 and the userid to set
+ // one against. It is not a failure, it is the first step.
if (envelope.code === 409 && envelope.details?.setup === true) {
throw new PasswordSetupRequiredError(envelope.details.userid ?? 0);
}
if (envelope.status !== true || !envelope.details) {
- throw new Error(envelope.message ?? 'Sign-in failed');
+ throw new Error(loginMessage(envelope.code, envelope.message));
}
const session = toSessionUser(envelope.details);
@@ -58,6 +77,29 @@ export async function login(email: string, password: string): Promise
-
-
-
- {label}
-
- {icon ? {icon} : null}
+
+ {/* Icon first, then the label; the pill sits opposite it. Both are
+ fixed to this row so tiles line up whether or not they carry one. */}
+ {/* A fixed height, so a label that wraps to two lines (a long note
+ squeezes it) does not push that tile's value below its neighbours'. */}
+
+
+ {icon ? (
+
+ {icon}
+
+ ) : null}
+
+ {label}
+
+
+
+ {note ? (
+
+ {note}
+
+ ) : null}
@@ -61,24 +100,6 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
{value}
-
- {note ? (
-
- {note}
-
- ) : null}
diff --git a/src/components/SectionHeader.tsx b/src/components/SectionHeader.tsx
index b44ea05..d5f18e6 100644
--- a/src/components/SectionHeader.tsx
+++ b/src/components/SectionHeader.tsx
@@ -6,13 +6,16 @@ import { HStack } from '@astryxdesign/core/HStack';
export interface SectionHeaderProps {
title: string;
/**
- * Sits inline beside the title, not beneath it.
+ * Sits inline beside the title, not beneath it — and only when it carries a
+ * fact, such as a count.
*
- * Required rather than optional on purpose: a bare heading tells the reader
- * what a block is called but not what it is showing them, and every section
- * in this console has something worth saying there.
+ * It used to be required, on the theory that every section should say what it
+ * is showing. In practice that produced a line of editorial beside every
+ * heading — "how today is going", "a queue nobody can see from the shop
+ * floor" — which reads as noise once you have seen the page twice. The
+ * heading and the data under it say enough.
*/
- note: string;
+ note?: string;
/** A control or a "View all" link, right-aligned. */
action?: ReactNode;
}
@@ -24,9 +27,11 @@ export function SectionHeader({ title, note, action }: SectionHeaderProps) {
{title}
-
- {note}
-
+ {note ? (
+
+ {note}
+
+ ) : null}
{action}
diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
index ae83b74..11b4bf3 100644
--- a/src/components/shell/AppShell.tsx
+++ b/src/components/shell/AppShell.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
-import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, Sparkles, X } from 'lucide-react';
+import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
@@ -49,6 +49,15 @@ export interface AppShellProps {
* here for.
*/
manageItems?: readonly MenuEntry[];
+ /**
+ * Workspace-specific controls in the header, left of the notification bell.
+ *
+ * For things a shop reaches from anywhere and that open in place rather than
+ * navigating — the store's QR code is the first. A page for it would have
+ * been a fifth destination for something that is looked at, printed once, and
+ * closed.
+ */
+ headerActions?: ReactNode;
}
/**
@@ -67,7 +76,14 @@ export interface AppShellProps {
* control — so those are props. Copying four hundred lines of chrome per role
* is how two headers drift apart and stop looking like one product.
*/
-export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: AppShellProps) {
+export function AppShell({
+ nav,
+ home,
+ navLabel,
+ scopeControl,
+ manageItems,
+ headerActions,
+}: AppShellProps) {
const { user, signOut } = useAuth();
const { pathname } = useLocation();
@@ -291,6 +307,8 @@ export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: App
+ {headerActions}
+
@@ -733,7 +751,7 @@ function initials(name: string): string {
* `label` is required, not optional — an icon-only control with no accessible
* name is a bug, so the API makes it impossible to omit.
*/
-function IconButton({
+export function IconButton({
label,
children,
onClick,
diff --git a/src/components/shell/AssistantPanel.tsx b/src/components/shell/AssistantPanel.tsx
index 20decc1..4f70d12 100644
--- a/src/components/shell/AssistantPanel.tsx
+++ b/src/components/shell/AssistantPanel.tsx
@@ -1,6 +1,13 @@
import { useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
+import {
+ DEFAULT_WIDTH,
+ expandedWidth,
+ MAX_WIDTH,
+ MIN_WIDTH,
+ useAssistantWidth,
+} from './assistantWidth';
/** Per-route context, so the panel knows which page it is sitting beside. */
const CONTEXT: Record<
@@ -135,19 +142,57 @@ export function AssistantPanel({
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef(null);
+ const { width, setWidth, reset } = useAssistantWidth();
+ const [isDragging, setDragging] = useState(false);
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
- const expandedStyle: React.CSSProperties = isExpanded
- ? {
- width: '50vw',
- maxWidth: 'none',
- }
- : {};
+ /**
+ * Three widths, in priority order: stacked (the phone layout owns it),
+ * expanded (the one-click half-screen), then whatever the operator dragged
+ * it to. The inline width beats the media queries in `index.css`, which is
+ * the point — those are the default, not the rule.
+ *
+ * Height is deliberately NOT set here. It was, briefly — measured off the
+ * page so a short page got a short panel — but that put a strip of page
+ * background under the panel on every screen that was not full. The column
+ * is full height, always, and `.assistant` owns that.
+ */
+ const sizeStyle: React.CSSProperties = isStacked
+ ? {}
+ : isExpanded
+ ? { width: expandedWidth(window.innerWidth), maxWidth: 'none' }
+ : { width, maxWidth: 'none' };
return (
-