store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -43,7 +43,15 @@ export function ImportDrawer({
const [categoryid, setCategoryid] = useState('');
const [subcategoryid, setSubcategoryid] = useState('');
const [retailprice, setRetailprice] = useState<number | null>(null);
/**
* Seeded from the bottom of the catalogue's price range.
*
* A guess, and a better starting point than an empty box — the operator
* changes it or accepts it, but does not have to go and look the product up.
* The range is a display string ("₹33-37"), so the number is parsed out of
* it rather than read from a field.
*/
const [retailprice, setRetailprice] = useState<number | null>(() => priceFloor(product.price_range));
const [productcost, setProductcost] = useState<number | null>(null);
const [taxpercent, setTaxpercent] = useState<number | null>(0);
const [quantity, setQuantity] = useState<number | null>(0);
@@ -102,6 +110,8 @@ export function ImportDrawer({
</VStack>
</HStack>
<CatalogueFacts product={product} />
<div className="form-grid">
<Selector
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
@@ -179,3 +189,68 @@ export function ImportDrawer({
</Dialog>
);
}
/**
* The lower bound of a catalogue price range.
*
* "₹33-37" → 33. It is a display string with no fixed shape, so the first
* number in it is taken and anything unparseable gives null rather than a
* fabricated price.
*/
function priceFloor(range: string | undefined): number | null {
if (!range) return null;
const match = range.replace(/,/g, '').match(/\d+(\.\d+)?/);
if (!match) return null;
const value = Number(match[0]);
return Number.isFinite(value) && value > 0 ? value : null;
}
/**
* What the catalogue knows and the import does not carry.
*
* The import copies seven of nineteen fields. These are among the twelve it
* leaves behind, and they are the ones that decide whether this is the right
* product: the licence, who sells it, and what is in it. Shown here because
* after the import they are only reachable by going back to the catalogue.
*/
function CatalogueFacts({ product }: { product: CatalogueProduct }) {
const facts: [string, string][] = [];
if (product.category) facts.push(['Catalogue category', product.category]);
if (product.product_sku) facts.push(['SKU', product.product_sku]);
if (product.fssai_license) facts.push(['FSSAI licence', product.fssai_license]);
if (product.providers?.length) facts.push(['Sold on', product.providers.join(', ')]);
if ((product.images?.length ?? 0) > 1) {
facts.push(['Photos', `${product.images?.length} — only the first is imported`]);
}
if (facts.length === 0 && !product.highlights?.length) return null;
return (
<VStack gap={1}>
{facts.length > 0 ? (
<VStack gap={0.5}>
{facts.map(([label, value]) => (
<HStack key={label} justify="between" align="start" gap={2}>
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
{label}
</Text>
<Text type="body" size="xsm" style={{ textAlign: 'right' }}>
{value}
</Text>
</HStack>
))}
</VStack>
) : null}
{product.highlights?.length ? (
<VStack gap={0}>
{product.highlights.slice(0, 4).map((line) => (
<Text key={line} type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
• {line}
</Text>
))}
</VStack>
) : null}
</VStack>
);
}

View File

@@ -201,7 +201,6 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
<VStack gap={2} padding={3}>
<SectionHeader
title="Upload the tenant's product list"
note="parsed in the browser, then written row by row"
action={
<Button
label="Download template"

View File

@@ -137,7 +137,7 @@ export function GlobalCataloguePage() {
is asked once, up front, rather than per product. */}
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<SectionHeader title="Stocking" note="which store these products go to" />
<SectionHeader title="Stocking" />
<div className="form-grid">
<Selector
label="Tenant"
@@ -173,7 +173,6 @@ export function GlobalCataloguePage() {
<VStack gap={1.5}>
<SectionHeader
title="Products"
note="filters narrow an already-visible list"
action={
<HStack gap={1} wrap="wrap">
<div style={{ width: 190 }}>

View File

@@ -15,13 +15,7 @@ import { PageBody } from '@/components/PageBody';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
const BUSINESS_CATEGORIES = [
{ value: '1', label: 'Grocery & Daily' },
{ value: '2', label: 'Fresh & Produce' },
{ value: '3', label: 'Pharmacy' },
{ value: '4', label: 'Bakery' },
];
import { useAppCategories } from '@/queries/hooks';
interface FormState {
tenantname: string;
@@ -68,6 +62,12 @@ export function OnboardTenantPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<FormState>(EMPTY);
/**
* The category list comes from `app_category`, not from four values typed
* into this file. A category added to the master should appear here without
* a release.
*/
const categories = useAppCategories();
const [error, setError] = useState<string | null>(null);
function set<K extends keyof FormState>(key: K) {
@@ -167,7 +167,6 @@ export function OnboardTenantPage() {
<VStack gap={2} padding={3}>
<SectionHeader
title="Business"
note="who the merchant is"
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid">
@@ -205,7 +204,11 @@ export function OnboardTenantPage() {
/>
<Selector
label="Business category"
options={BUSINESS_CATEGORIES}
options={(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
}))}
isDisabled={categories.isLoading}
value={form.categoryid}
onChange={set('categoryid')}
/>
@@ -217,7 +220,6 @@ export function OnboardTenantPage() {
<VStack gap={2} padding={3}>
<SectionHeader
title="Head office"
note="where the business is registered"
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<TextInput
@@ -259,10 +261,6 @@ export function OnboardTenantPage() {
) : null}
<HStack justify="between" align="center" gap={2} wrap="wrap">
<Text type="body" size="xsm" color="secondary">
Products are added after the tenant exists — both import paths need its tenantid and
locationid.
</Text>
<Button
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
type="submit"

View File

@@ -238,7 +238,6 @@ export function StoreDetailPage() {
<VStack gap={1.5}>
<SectionHeader
title="Branches"
note="orders and revenue are online only — counter sales are per branch"
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
/>

View File

@@ -4,6 +4,7 @@ import { Badge } from '@astryxdesign/core/Badge';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Table, type TableColumn } from '@astryxdesign/core/Table';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
@@ -12,7 +13,7 @@ import { DataState } from '@/components/DataState';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
import { useTenants } from '@/queries/hooks';
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
import type { TenantInfo } from '@/api/types';
/** A tenant, with its branches folded in. */
@@ -34,15 +35,39 @@ interface TenantRow extends Record<string, unknown> {
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
* branches reads as six tenants.
*/
type Tab = 'directory' | 'pending';
/** Rows per page. One more than this is fetched, to know whether there is a next. */
const PAGE_SIZE = 50;
export function StoresPage() {
const { data, isLoading, error } = useTenants();
const [tab, setTab] = useState<Tab>('directory');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
/**
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
* and has no total, so paging is "ask for one more than we show and see if it
* comes back". A platform list read whole is fine at twenty tenants and not
* at two thousand.
*/
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
/**
* The queue of merchants awaiting approval.
*
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
* `getalltenants` at all, so without this they are invisible. Nothing here
* can approve one — `approved` is writable only at creation — so this lists
* and says so.
*/
const pending = useTenantsByApproval('pending');
const rows = useMemo<TenantRow[]>(() => {
if (!data) return [];
const grouped = new Map<number, TenantRow>();
for (const tenant of data as TenantInfo[]) {
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
const existing = grouped.get(tenant.tenantid);
if (existing) {
existing.branches += 1;
@@ -196,6 +221,23 @@ export function StoresPage() {
/>
</div>
<HStack gap={0.5} wrap="wrap">
<TabButton
label="Directory"
isActive={tab === 'directory'}
onClick={() => setTab('directory')}
/>
<TabButton
label="Awaiting approval"
isActive={tab === 'pending'}
badge={pending.data?.length || undefined}
onClick={() => setTab('pending')}
/>
</HStack>
{tab === 'pending' ? (
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
) : (
<VStack gap={1.5}>
<SectionHeader
title="Tenant directory"
@@ -246,7 +288,200 @@ export function StoresPage() {
</div>
</DataState>
</Card>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
Page {page}
{search ? ` · filtered from ${rows.length} on this page` : ''}
</Text>
<HStack gap={1}>
<Button
label="Previous"
variant="secondary"
size="sm"
isDisabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
onClick={() => setPage((current) => current + 1)}
/>
</HStack>
</HStack>
</VStack>
)}
</VStack>
);
}
/**
* Merchants that have been created but never approved.
*
* Read-only, and it says why on the panel rather than offering a button that
* cannot work: `tenants.approved` is written once, at creation, and there is no
* update or approve endpoint anywhere in the backend. Until there is, this is
* the only place these rows are visible at all — `getalltenants` cannot see
* them.
*/
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack padding={3}>
<Text type="body" size="sm" color="secondary">
Reading the approval queue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing waiting
</Text>
<Text type="body" size="sm" color="secondary">
Every merchant on the platform has been approved.
</Text>
</VStack>
</Card>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
These merchants exist but are not approved, so they do not appear in the directory.
Approving is a database change today — the backend has no endpoint for it.
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Merchant</Th>
<Th>Company</Th>
<Th>Contact</Th>
<Th>City</Th>
<Th>Tenant id</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.tenantid}-${row.locationid}`}>
<Td isStrong>{row.tenantname}</Td>
<Td isMuted>{row.companyname || '—'}</Td>
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
<Td isMuted>{row.city || '—'}</Td>
<Td isMuted>{row.tenantid}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
function TabButton({
label,
isActive,
onClick,
badge,
}: {
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
}}
>
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
}}
>
{children}
</td>
);
}

View File

@@ -26,6 +26,8 @@ export interface BranchScopeValue {
scoped: TenantLocation[];
/** The selected branch, or undefined under All. */
current: TenantLocation | undefined;
/** True in the Store user workspace: one branch, fixed, `select` inert. */
isPinned: boolean;
}
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
@@ -46,26 +48,50 @@ const PARAM = 'branch';
* alone — putting the tenant id in an editable address bar would turn the one
* boundary we control into a text field.
*/
export function BranchScopeProvider({ children }: { children: ReactNode }) {
export function BranchScopeProvider({
children,
pin,
}: {
children: ReactNode;
/**
* Fix the scope to one branch — the Store user workspace.
*
* When set, `selected` is always this id, `branches` and `scoped` hold only
* that outlet, and `select` does nothing. The URL param is ignored rather
* than merely defaulted: Fiesta authorises a POS or catalogue read on
* `locationid` alone, so an address bar that can change it is not a filter,
* it is the authorisation boundary in a text field.
*/
pin?: number;
}) {
const { user } = useAuth();
const [params, setParams] = useSearchParams();
const tenantid = user?.tenantid ?? 0;
const { data, isLoading } = useTenantLocations(tenantid || undefined);
const branches = useMemo(
const all = useMemo(
() => [...(data ?? [])].sort((a, b) => a.locationid - b.locationid),
[data],
);
// Pinned: the one outlet, and nothing else is ever in the list. Unpinned: all
// of them, with All-branches available.
const branches = useMemo(
() => (pin === undefined ? all : all.filter((branch) => branch.locationid === pin)),
[all, pin],
);
const raw = params.get(PARAM);
const parsed = raw === null || raw === 'all' ? null : Number(raw);
// An id in the URL that this tenant does not own falls back to All rather
// than showing an empty page — the id is user-editable, so it is untrusted.
const selected =
parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
? parsed
: null;
pin !== undefined
? pin
: parsed !== null && Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)
? parsed
: null;
const value = useMemo<BranchScopeValue>(() => {
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
@@ -75,15 +101,17 @@ export function BranchScopeProvider({ children }: { children: ReactNode }) {
tenantid,
selected,
current,
isPinned: pin !== undefined,
scoped: current ? [current] : branches,
select: (next) => {
if (pin !== undefined) return;
const nextParams = new URLSearchParams(params);
if (next === null) nextParams.delete(PARAM);
else nextParams.set(PARAM, String(next));
setParams(nextParams, { replace: true });
},
};
}, [branches, isLoading, tenantid, selected, params, setParams]);
}, [branches, isLoading, tenantid, selected, params, setParams, pin]);
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
}

View File

@@ -0,0 +1,280 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Check, DownloadCloud, Info } from 'lucide-react';
import { catalogueKey } from '@/api/catalogue';
import { productsApi } from '@/api/products';
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import {
useCatalogueBrands,
useCatalogueProducts,
useImportedRefs,
useTenantCategories,
} from '@/queries/hooks';
import { Drawer } from './Drawer';
import { MediaPill, ProductCard } from './ProductCard';
/**
* Import from the global catalogue, without leaving Inventory.
*
* A drawer rather than a page because importing is one move inside the pricing
* job, not a destination: you come here because a product is missing from the
* list behind, and you want to go straight back to it.
*
* Two things follow the old console because it had them right, and one
* deliberately does not:
*
* - **One click imports.** No confirmation. Import is reversible in the sense
* that matters — the product lands unpriced and unreleased, so nothing
* reaches a till until someone prices it.
* - **The "Imported" badge is keyed on `brand:catalogueid`.** The old console
* compared a catalogue id against tenant PRODUCT ids, which are different
* number spaces, so its badge was effectively random. `getimportedcatalogueproducts`
* returns exactly the right pairs and it fetched them two lines above without
* ever reading them.
*/
export function CatalogueImportDrawer({
tenantid,
locationid,
onClose,
}: {
tenantid: number;
locationid: number;
onClose: () => void;
}) {
const client = useQueryClient();
const [brand, setBrand] = useState('');
const [keyword, setKeyword] = useState('');
const [debounced, setDebounced] = useState('');
const [categoryid, setCategoryid] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [justImported, setJustImported] = useState<Set<string>>(new Set());
useEffect(() => {
const id = setTimeout(() => setDebounced(keyword.trim()), 400);
return () => clearTimeout(id);
}, [keyword]);
const brands = useCatalogueBrands();
const categories = useTenantCategories(tenantid);
const imported = useImportedRefs(tenantid);
const products = useCatalogueProducts({
...(brand ? { brand } : {}),
...(debounced ? { keyword: debounced } : {}),
pagesize: 48,
});
const importedKeys = useMemo(() => {
const set = new Set((imported.data ?? []).map(catalogueKey));
for (const key of justImported) set.add(key);
return set;
}, [imported.data, justImported]);
const importOne = useMutation({
mutationFn: (row: ImportCatalogueProductRequest) => productsApi.importFromCatalogue([row]),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
client.invalidateQueries({ queryKey: queryKeys.products.all }),
]);
},
});
async function run(product: CatalogueProduct) {
const key = catalogueKey(product);
setBusy(key);
try {
await importOne.mutateAsync({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
// Uncategorised beats wrongly categorised. The backend makes this field
// optional for exactly that reason: "an unclassified product is visibly
// unfinished, while a wrongly classified one looks done and is only
// found by someone browsing the wrong aisle."
categoryid: Number(categoryid) || 0,
subcategoryid: 0,
quantity: 0,
stocktype: 'in',
status: 'Draft',
// Zero on purpose. Import is not pricing — the product lands in the
// "Not ready" list behind this drawer, which is where a price is set.
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
setJustImported((set) => new Set(set).add(key));
} finally {
setBusy(null);
}
}
const brandOptions = useMemo(
() => [
{ value: '', label: 'All brands' },
...(brands.data ?? []).map((entry) => ({
value: entry.brand,
label: `${entry.brand.replace('brand_', '').toUpperCase()} (${entry.product_count})`,
})),
],
[brands.data],
);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'Leave uncategorised' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
const rows = products.data ?? [];
return (
<Drawer
title="Add products"
subtitle="Pick what you sell from the global catalogue. Prices are set afterwards."
width={920}
isBare
onClose={onClose}
>
<VStack gap={1.5} padding={2} style={{ flex: 'none' }}>
<HStack gap={1} wrap="wrap">
<div style={{ width: 200 }}>
<Selector
label="Brand"
isLabelHidden
size="sm"
value={brand}
onChange={setBrand}
placeholder="All brands"
options={brandOptions}
/>
</div>
<div style={{ width: 220 }}>
<TextInput
label="Search the catalogue"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search the catalogue…"
hasClear
/>
</div>
<div style={{ width: 210 }}>
<Selector
label="Category to import into"
isLabelHidden
size="sm"
value={categoryid}
onChange={setCategoryid}
placeholder="Leave uncategorised"
options={categoryOptions}
/>
</div>
</HStack>
<HStack gap={1} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 1 }}>
<Info size={13} />
</span>
<Text type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
Imported products arrive with no price and are not released to your shops. They
appear under <strong>Not ready</strong> behind this panel, where you set the price.
</Text>
</HStack>
</VStack>
<div className="import-scroll" style={{ padding: 16, paddingTop: 0 }}>
{products.isLoading ? (
<Text type="body" size="sm" color="secondary">
Loading the catalogue…
</Text>
) : rows.length === 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
No products match your selection
</Text>
<Text type="body" size="sm" color="secondary">
Try a different brand, or clear the search.
</Text>
</VStack>
) : (
<div className="product-grid">
{rows.map((product) => {
const key = catalogueKey(product);
const isImported = importedKeys.has(key);
const images = product.images ?? [];
return (
<ProductCard
key={key}
images={images}
title={product.product_name}
identifier={product.product_sku ?? `ID: ${product.id}`}
left={product.size || '—'}
right={product.price_range || '—'}
mediaBadge={
<MediaPill label={product.brand.replace('brand_', '')} color="var(--color-brand)" />
}
{...(isImported
? {
mediaStatus: (
<MediaPill label="In your list" color="#10b981" isSolid />
),
}
: {})}
action={
isImported ? (
<Button
label="Added"
variant="secondary"
size="sm"
icon={<Check size={12} />}
isDisabled
width="full"
/>
) : (
<Button
label={busy === key ? 'Adding…' : 'Add to my products'}
variant="primary"
size="sm"
icon={<DownloadCloud size={12} />}
isDisabled={busy !== null}
width="full"
onClick={() => void run(product)}
/>
)
}
/>
);
})}
</div>
)}
{/* The photo count the import silently drops. 90 of Nestlé's 123
products carry more than one image and only the first survives. */}
{rows.some((product) => (product.images?.length ?? 0) > 1) ? (
<Text
type="body"
size="xsm"
style={{ color: 'var(--color-ink-4)', display: 'block', paddingTop: 12 }}
>
Products with several photos import only the first one.
</Text>
) : null}
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,135 @@
import { useEffect, type ReactNode } from 'react';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { X } from 'lucide-react';
/**
* The side drawer, matched to `OrderDetailDrawer`.
*
* That one is the site's drawer and every measurement here is taken from it:
* the flex-end wrapper at z-index 70, a full-bleed scrim BUTTON rather than a
* div (so it is reachable and announces itself), a 460px panel capped at 92vw,
* `-24px 0 56px -12px` of shadow, a STICKY header on a hairline, and a 30px
* rounded close button on `--color-surface-sunken`.
*
* Extracted rather than copied a third time. The drawers I added had drifted —
* different scrim opacity, different shadow, a bare `X`, a header that scrolled
* away — which is exactly how two screens stop looking like one product.
*/
export interface DrawerProps {
title: string;
/** One line under the title. */
subtitle?: string;
/** Sits beside the title — a status chip, usually. */
titleAside?: ReactNode;
/**
* Panel width in px. 460 is the site default; only widen for content that
* genuinely cannot fold, such as a grid of product cards.
*/
width?: number;
/** Renders the body unpadded, for panels that manage their own layout. */
isBare?: boolean;
onClose: () => void;
children: ReactNode;
}
export function Drawer({
title,
subtitle,
titleAside,
width = 460,
isBare,
onClose,
children,
}: DrawerProps) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{ position: 'fixed', inset: 0, zIndex: 70, display: 'flex', justifyContent: 'flex-end' }}
>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
position: 'absolute',
inset: 0,
border: 0,
background: 'rgb(15 23 42 / .32)',
cursor: 'pointer',
}}
/>
<div
className="dialog-panel"
style={{
position: 'relative',
width,
maxWidth: '92vw',
height: '100%',
overflowY: 'auto',
background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-line)',
boxShadow: '-24px 0 56px -12px rgb(15 23 42 / .18)',
}}
>
<HStack
justify="between"
align="start"
gap={1}
style={{
position: 'sticky',
top: 0,
zIndex: 1,
padding: 20,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
<VStack gap={0.5} style={{ minWidth: 0 }}>
<Text type="label" size="lg" weight="semibold">
{title}
</Text>
{subtitle ? (
<Text type="body" size="sm" color="secondary">
{subtitle}
</Text>
) : null}
{titleAside}
</VStack>
<button
type="button"
aria-label="Close"
onClick={onClose}
style={{
width: 30,
height: 30,
borderRadius: 10,
border: 0,
flex: 'none',
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface-sunken)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
}}
>
<X size={15} />
</button>
</HStack>
{isBare ? children : <VStack gap={3} padding={2}>{children}</VStack>}
</div>
</div>
);
}

View File

@@ -321,6 +321,7 @@ export function TillDrawer({
locationid,
roles,
shifts,
cashierOnly = false,
onClose,
}: {
row: PosUser | null;
@@ -328,13 +329,22 @@ export function TillDrawer({
locationid: number;
roles: PosRole[];
shifts: StaffShift[];
/**
* True in the Store user workspace.
*
* A shop provisions cashiers, not supervisors. The backend agrees from the
* other side — only roleid 7 may manage till users — so a supervisor is
* issued by the merchant and creates their own cashiers at the terminal. The
* picker is absent rather than disabled: there is nothing to choose.
*/
cashierOnly?: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const isNew = row === null;
const [fullName, setFullName] = useState(row?.full_name ?? '');
const [role, setRole] = useState(row?.role?.toLowerCase() ?? 'cashier');
const [role, setRole] = useState(cashierOnly ? 'cashier' : (row?.role?.toLowerCase() ?? 'cashier'));
const [contactno, setContactno] = useState(row?.contactno ?? '');
const [pin, setPin] = useState('');
const [shiftId, setShiftId] = useState(String(row?.shift_id ?? 0));
@@ -454,7 +464,13 @@ export function TillDrawer({
return (
<Sheet
title={isNew ? 'Add a till account' : (row.full_name ?? 'Edit till account')}
title={
isNew
? cashierOnly
? 'Add a cashier'
: 'Add a till account'
: (row.full_name ?? 'Edit till account')
}
subtitle={
isNew ? 'Signs in at a till — no console login' : `Till user ${row.user_id}`
}
@@ -477,12 +493,29 @@ export function TillDrawer({
<TextInput label={<span>Full name <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={fullName} onChange={setFullName} />
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
{chosenRole?.description ? (
<Text type="body" size="xsm" color="secondary">
{chosenRole.description}
</Text>
) : null}
{/* The role is shown either way — a form that silently decides a
field leaves the reader wondering what was decided. A shop just
cannot change it: the merchant issues supervisors, and the backend
only lets roleid 7 manage till users at all. */}
{cashierOnly ? (
<Selector
label="Role"
value="cashier"
onChange={() => undefined}
options={[{ value: 'cashier', label: 'Cashier' }]}
isDisabled
description="Supervisors are issued by your store administrator."
/>
) : (
<>
<Selector label="Role" value={role} onChange={setRole} options={roleOptions} />
{chosenRole?.description ? (
<Text type="body" size="xsm" color="secondary">
{chosenRole.description}
</Text>
) : null}
</>
)}
<TextInput
label={<span>Mobile <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}

View File

@@ -0,0 +1,369 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
/**
* The product card, matched to the old console's.
*
* The proportions and treatment are lifted deliberately rather than reinvented,
* because staff already read them: a **160px white media band** with the image
* inset 12px and `object-contain`, a two-line bold name, the SKU in mono
* underneath, a hairline, then one line of meta with a figure right-aligned,
* then a single full-width action.
*
* `mix-blend-multiply` is the detail that makes it look right and is easy to
* miss. Catalogue photography is shot on white, so without it every card
* carries a visible grey rectangle where the photo's background meets the card.
*/
export interface ProductCardProps {
images: string[];
title: string;
/** The SKU or catalogue id — mono, under the title. */
identifier?: string;
/** Left-hand meta, e.g. "Unit: 500g". */
left?: React.ReactNode;
/** Right-hand figure, e.g. a price. Mono and bold. */
right?: React.ReactNode;
/**
* One short line under the SKU — why this product cannot be sold.
*
* A pill can say "Not ready"; only a sentence can say which of the two
* things is missing, and that is the whole difference between a card the
* merchant can act on and one that just worries them.
*/
note?: string;
noteColor?: string;
/** A badge pinned to the top-left of the image. */
mediaBadge?: React.ReactNode;
/** A badge pinned to the top-right of the image. */
mediaStatus?: React.ReactNode;
action?: React.ReactNode;
onClick?: () => void;
}
export function ProductCard({
images,
title,
identifier,
note,
noteColor,
left,
right,
mediaBadge,
mediaStatus,
action,
onClick,
}: ProductCardProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
minWidth: 0,
borderRadius: 16,
border: `1px solid ${isHovered ? 'color-mix(in oklab, var(--color-brand) 40%, transparent)' : 'var(--color-line)'}`,
background: 'var(--color-surface)',
overflow: 'hidden',
cursor: onClick ? 'pointer' : 'default',
boxShadow: isHovered
? '0 8px 20px -6px rgb(16 24 40 / .12)'
: '0 1px 2px rgb(16 24 40 / .05)',
transition: 'box-shadow .2s, border-color .2s',
}}
>
<Media images={images} alt={title} isHovered={isHovered} badge={mediaBadge} status={mediaStatus} />
<div
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 8,
padding: 12,
minWidth: 0,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<h4
title={title}
style={{
margin: 0,
fontSize: 14,
fontWeight: 700,
lineHeight: 1.35,
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-1)',
transition: 'color .2s',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</h4>
{identifier ? (
<p
style={{
margin: '2px 0 0',
fontSize: 12,
fontWeight: 600,
fontFamily: 'var(--font-mono)',
letterSpacing: '-.01em',
color: 'var(--color-ink-4)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{identifier}
</p>
) : null}
{note ? (
<p
style={{
margin: '4px 0 0',
fontSize: 11.5,
lineHeight: 1.45,
color: noteColor ?? 'var(--color-ink-4)',
}}
>
{note}
</p>
) : null}
</div>
<div
style={{
marginTop: 'auto',
paddingTop: 8,
borderTop: '1px solid var(--color-line)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{left !== undefined || right !== undefined ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontSize: 12,
color: 'var(--color-ink-3)',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{left}
</span>
<span
style={{
flex: 'none',
fontWeight: 700,
fontFamily: 'var(--font-mono)',
color: 'var(--color-ink-1)',
}}
>
{right}
</span>
</div>
) : null}
{action}
</div>
</div>
</div>
);
}
/**
* The 160px media band.
*
* Arrows and dots appear only when there is more than one image, and only on
* hover — and every handler stops propagation, or paging through photos would
* open the card instead.
*/
function Media({
images,
alt,
isHovered,
badge,
status,
}: {
images: string[];
alt: string;
isHovered: boolean;
badge?: React.ReactNode;
status?: React.ReactNode;
}) {
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
const current = images[Math.min(index, images.length - 1)];
const hasMany = images.length > 1;
return (
<div
style={{
position: 'relative',
width: '100%',
height: 160,
flex: 'none',
padding: 12,
display: 'grid',
placeItems: 'center',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
overflow: 'hidden',
}}
>
{current && !failed ? (
<img
src={current}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
// Catalogue shots are on white; without this the photo's ground
// shows as a grey rectangle inside the card.
mixBlendMode: 'multiply',
transform: isHovered ? 'scale(1.08)' : 'scale(1)',
transition: 'transform .7s ease-in-out',
}}
/>
) : (
<span style={{ color: 'var(--color-line)' }}>
<ImageOff size={44} />
</span>
)}
{badge ? <span style={{ position: 'absolute', top: 8, left: 8, zIndex: 2 }}>{badge}</span> : null}
{status ? <span style={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }}>{status}</span> : null}
{hasMany ? (
<>
<Arrow
side="left"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value - 1 + images.length) % images.length);
}}
/>
<Arrow
side="right"
isVisible={isHovered}
onClick={(event) => {
event.stopPropagation();
setFailed(false);
setIndex((value) => (value + 1) % images.length);
}}
/>
<span
style={{
position: 'absolute',
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 3,
zIndex: 2,
}}
>
{images.slice(0, 8).map((url, position) => (
<span
key={url}
style={{
width: position === index ? 12 : 5,
height: 5,
borderRadius: 999,
background:
position === index ? 'var(--color-brand)' : 'var(--color-line)',
transition: 'width .2s',
}}
/>
))}
</span>
</>
) : null}
</div>
);
}
function Arrow({
side,
isVisible,
onClick,
}: {
side: 'left' | 'right';
isVisible: boolean;
onClick: (event: React.MouseEvent) => void;
}) {
return (
<button
type="button"
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
onClick={onClick}
style={{
position: 'absolute',
[side]: 4,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2,
width: 26,
height: 26,
display: 'grid',
placeItems: 'center',
borderRadius: 999,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-3)',
cursor: 'pointer',
opacity: isVisible ? 1 : 0,
transition: 'opacity .2s',
}}
>
{side === 'left' ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
);
}
/** The small uppercase pill the old console pins over a product image. */
export function MediaPill({
label,
color,
isSolid,
}: {
label: string;
color: string;
isSolid?: boolean;
}) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: '.06em',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
background: isSolid ? color : `color-mix(in srgb, ${color} 12%, var(--color-surface))`,
color: isSolid ? '#fff' : color,
border: isSolid ? 'none' : `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
}}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,362 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { EyeOff } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { useCatalogueProduct } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { Drawer } from './Drawer';
import { branchLabel, count, money } from './format';
import {
blockedReason,
effectivePrice,
imagesOf,
isPublished,
stockOf,
STATE_COLOR,
STATE_LABEL,
stateOf,
} from './productState';
/**
* One product, in full.
*
* The import copies seven of the global catalogue's nineteen fields — name,
* description, sku, brand, catalogueid, size and images — and leaves the rest
* behind: highlights, nutrition, FSSAI licence, providers, price range,
* variant key. Those are exactly what someone needs when deciding what to
* charge, so this drawer goes back to the catalogue for them.
*
* That lookup often finds nothing, and that is expected rather than an error:
* the tenant's product is a SNAPSHOT, so it outlives its source row whenever a
* re-scrape retires a variant. The panel says so instead of showing a blank.
*/
export function ProductDrawer({
product,
canManage = true,
onClose,
}: {
product: Product;
/**
* False in the Store user workspace.
*
* Hiding a product is a tenant-wide write — `unpublishproduct` clears
* `publishedat` at EVERY outlet — so a branch must not be given it. The
* control is absent rather than disabled: a disabled button invites the
* question "why", and the answer is that this is not a decision a shop makes.
*/
canManage?: boolean;
onClose: () => void;
}) {
const { tenantid, current, scoped } = useBranchScope();
const client = useQueryClient();
const [confirmHide, setConfirmHide] = useState(false);
const source = useCatalogueProduct(product.productbrand, product.productsku);
const images = imagesOf(product);
const state = stateOf(product);
const reason = blockedReason(product);
const branch = current ?? scoped[0];
const unpublish = useMutation({
mutationFn: () =>
productsApi.unpublish({ tenantid: tenantid as number, productid: product.productid }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.products.all });
onClose();
},
});
return (
<Drawer
title={product.productname ?? 'Product'}
subtitle={[product.productbrand, product.productsku].filter(Boolean).join(' · ') || undefined}
titleAside={
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
<Chip label={STATE_LABEL[state]} color={STATE_COLOR[state]} />
</HStack>
}
onClose={onClose}
>
{/* ── Money ─────────────────────────────────────────────────────── */}
<div className="form-grid">
<Figure
label="Selling price"
value={effectivePrice(product) > 0 ? money(effectivePrice(product)) : 'not set'}
isStrong
/>
<Figure label="Cost" value={product.productcost ? money(product.productcost) : '—'} />
<Figure label="Tax" value={`${product.taxpercent ?? 0}%`} />
</div>
{reason ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
{reason}
</Text>
) : null}
{/* ── Pack shots ────────────────────────────────────────────────── */}
{images.length > 0 ? (
<VStack gap={1}>
<Label>Pack</Label>
<HStack gap={1} wrap="wrap">
{images.slice(0, 4).map((url) => (
<img
key={url}
src={url}
alt=""
style={{
width: 76,
height: 76,
objectFit: 'contain',
mixBlendMode: 'multiply',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
}}
/>
))}
</HStack>
</VStack>
) : null}
{/* ── In your shops ─────────────────────────────────────────────── */}
<VStack gap={1}>
<Label>In your shops</Label>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Line
label={branch ? `Stock at ${branchLabel(branch.locationname)}` : 'Stock'}
value={count(stockOf(product))}
/>
<Line label="Released to shops" value={isPublished(product) ? 'yes' : 'not yet'} />
<Line
label="Category"
value={product.categoryname || product.Subcategoryname || 'uncategorised'}
/>
<Line
label="Unit"
value={[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
/>
</VStack>
</Card>
</VStack>
{product.productdesc ? (
<VStack gap={1}>
<Label>Description</Label>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{product.productdesc}
</Text>
</VStack>
) : null}
{/* ── The fields the import left behind ─────────────────────────── */}
{source.isLoading ? (
<Text type="body" size="xsm" color="secondary">
Looking up the full catalogue entry…
</Text>
) : source.data ? (
<CatalogueExtras data={source.data} />
) : (
<VStack gap={1}>
<Label>Catalogue entry</Label>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
Not available — this item has been revised in the global catalogue since you imported
it. Your copy is unaffected; only the extra reference details are gone.
</Text>
</VStack>
)}
{canManage && isPublished(product) ? (
<VStack gap={1}>
<div style={{ height: 1, background: 'var(--color-line)' }} />
{confirmHide ? (
<VStack gap={1}>
{/*
The honest description. `publishedat` is read by exactly one
query in the whole backend and filtered by none: the till gates
on `price > 0 && status != 'outofstock'`, and the customer-app
feed does not select the column at all. So this hides the
product HERE and nowhere else.
*/}
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
This hides <strong>{product.productname}</strong> from your shops&rsquo; product
list. It does <strong>not</strong> take it off the till or out of the customer app
— those go by price and stock, not by this setting. To stop selling it, set its
stock to zero.
</Text>
<HStack gap={1} justify="end" wrap="wrap">
<Button
label="Cancel"
variant="ghost"
size="sm"
onClick={() => setConfirmHide(false)}
/>
<Button
label={unpublish.isPending ? 'Hiding…' : 'Hide from the list'}
variant="secondary"
size="sm"
onClick={() => unpublish.mutate()}
isDisabled={unpublish.isPending || !tenantid}
/>
</HStack>
</VStack>
) : (
<Button
label="Hide from the store catalogue"
variant="ghost"
size="sm"
icon={<EyeOff size={13} />}
onClick={() => setConfirmHide(true)}
/>
)}
</VStack>
) : null}
</Drawer>
);
}
/**
* The catalogue's own reference data.
*
* Laid out as figure tiles and a card, the same as everything above it — a
* two-column key/value list was the thing that made this drawer read as a
* different product from the order drawer beside it.
*/
function CatalogueExtras({
data,
}: {
data: NonNullable<ReturnType<typeof useCatalogueProduct>['data']>;
}) {
const lines: [string, string][] = [];
if (data.category) lines.push(['Catalogue category', data.category]);
if (data.fssai_license) lines.push(['FSSAI licence', data.fssai_license]);
if (data.providers?.length) lines.push(['Sold by', data.providers.join(', ')]);
return (
<VStack gap={1}>
<Label>From the global catalogue</Label>
{data.size || data.price_range ? (
<div className="form-grid">
{data.size ? <Figure label="Pack size" value={data.size} /> : null}
{data.price_range ? <Figure label="Typical retail" value={data.price_range} /> : null}
</div>
) : null}
{lines.length > 0 ? (
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
{lines.map(([label, value]) => (
<Line key={label} label={label} value={value} />
))}
</VStack>
</Card>
) : null}
{data.highlights?.length ? (
<VStack gap={0}>
{data.highlights.map((line) => (
<Text key={line} type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
• {line}
</Text>
))}
</VStack>
) : null}
{data.nutrients?.length ? (
<VStack gap={1}>
<Label>Nutrition</Label>
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={2}>
{data.nutrients.map((line) => (
<Text key={line} type="body" size="sm" color="secondary">
{line}
</Text>
))}
</VStack>
</Card>
</VStack>
) : null}
</VStack>
);
}
/* ── Bits, taken from OrderDetailDrawer so the two drawers read alike ────── */
function Figure({ label, value, isStrong }: { label: string; value: string; isStrong?: boolean }) {
return (
<VStack gap={0}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text
type="label"
size={isStrong ? 'lg' : 'sm'}
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Text>
</VStack>
);
}
function Line({ label, value }: { label: string; value: string }) {
return (
<HStack justify="between" align="start" gap={2}>
<Text type="body" size="sm" color="secondary" style={{ flex: 'none' }}>
{label}
</Text>
<Text type="body" size="sm" style={{ textAlign: 'right' }}>
{value}
</Text>
</HStack>
);
}
const Label = ({ children }: { children: React.ReactNode }) => (
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{children}
</Text>
);
/** The status chip, same shape as the tables use. */
function Chip({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
whiteSpace: 'nowrap',
color,
background: `color-mix(in oklab, ${color} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
}}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,483 @@
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { ImageOff } from 'lucide-react';
import type { Product } from '@/api/types';
import { SectionHeader } from '@/components/SectionHeader';
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
import { useBranchScope } from './BranchScope';
import { CatalogueImportDrawer } from './CatalogueImportDrawer';
import { count, money } from './format';
import { ProductDrawer } from './ProductDrawer';
import { PublishDialog } from './PublishDialog';
import { SheetUploadDrawer } from './SheetUploadDrawer';
import {
effectivePrice,
imagesOf,
stateOf,
stockOf,
STATE_COLOR,
STATE_LABEL,
type ProductState,
} from './productState';
/**
* Products — a table, in the shape every other page on this console uses.
*
* It was a grid of image cards, lifted from the old console. On its own that
* read well; sitting beside Sales, Reports and Console it read as a different
* product. Those pages are all the same thing — a white card holding a table,
* hairline rows, a soft status chip in the row's own colour, and an arrow link
* at the end — so this is that, and the photograph survives as a 40px thumbnail
* in the first column rather than a 160px band.
*
* The three states are still derived in `productState.ts` from price,
* `publishedat` and the ledger balance, because no single backend column tells
* the truth on its own. They are shown per row, not as a filter.
*/
export function ProductsPanel({
isImporting,
onCloseImport,
isUploading,
onCloseUpload,
}: {
/** Owned by InventoryPage, so its buttons can sit in the tab row. */
isImporting: boolean;
onCloseImport: () => void;
isUploading: boolean;
onCloseUpload: () => void;
}) {
const { scoped, current, tenantid } = useBranchScope();
const [category, setCategory] = useState('');
const [open, setOpen] = useState<Product | null>(null);
const [publishing, setPublishing] = useState<Product[] | null>(null);
/**
* One branch, always.
*
* `getlocationproducts` takes a single `locationid` and there is no
* tenant-wide product read. Under "All branches" this reads the first.
* Publishing is tenant-wide regardless, so the choice affects what is
* LISTED, not what is changed.
*/
const branch = current ?? scoped[0];
const products = useLocationProducts(tenantid || undefined, branch?.locationid, 0);
const categories = useTenantCategories(tenantid || undefined);
/**
* One list, ordered by what needs doing.
*
* Every row carries its own status chip and, when something blocks it, the
* sentence saying which — so the work queue is the top of the list rather
* than a filter that hides two thirds of the catalogue.
*/
const rows = useMemo(() => {
const filtered = (products.data ?? []).filter(
(product) => !category || String(product.categoryid ?? '') === category,
);
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
return [...filtered].sort((a, b) => {
const byState = rank[stateOf(a)] - rank[stateOf(b)];
if (byState !== 0) return byState;
return (a.productname ?? '').localeCompare(b.productname ?? '');
});
}, [products.data, category]);
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All categories' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
return (
<VStack gap={1.5}>
<SectionHeader
title="Products"
action={
<div style={{ width: 190 }}>
<Selector
label="Category"
isLabelHidden
size="sm"
value={category}
onChange={setCategory}
placeholder="All categories"
options={categoryOptions}
/>
</div>
}
/>
<ProductTable
rows={rows}
isLoading={products.isLoading}
onOpen={setOpen}
onPrice={(product) => setPublishing([product])}
/>
{open ? <ProductDrawer product={open} onClose={() => setOpen(null)} /> : null}
{isUploading ? (
<SheetUploadDrawer
tenantid={tenantid || undefined}
locationid={branch?.locationid}
onClose={onCloseUpload}
/>
) : null}
{isImporting && tenantid && branch ? (
<CatalogueImportDrawer
tenantid={tenantid}
locationid={branch.locationid}
onClose={onCloseImport}
/>
) : null}
{publishing && tenantid ? (
<PublishDialog
products={publishing}
tenantid={tenantid}
branchCount={scoped.length}
onClose={() => setPublishing(null)}
/>
) : null}
</VStack>
);
}
/* ── Table ───────────────────────────────────────────────────────────────── */
function ProductTable({
rows,
isLoading,
onOpen,
onPrice,
}: {
rows: Product[];
isLoading: boolean;
onOpen: (product: Product) => void;
onPrice: (product: Product) => void;
}) {
if (isLoading) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Reading your catalogue…
</Text>
</VStack>
</Card>
);
}
if (rows.length === 0) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
Nothing here yet
</Text>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
Use <strong>Import products</strong> to pick what you sell from the global catalogue,
or <strong>Upload sheet</strong> for your own list. Clear the category filter if you
were expecting rows.
</Text>
</VStack>
</Card>
);
}
return (
<Card padding={0} elevation="low">
<div className="table-scroll">
{/* `table-layout: fixed` with an explicit colgroup. Without it the
browser sizes every column to its content, so Category and Status
sprawled while Unit and Stock were squeezed — and the widths
shifted every time the rows changed. */}
{/* Every column left-aligned and sized to its widest value plus the
SAME constant, so the gap between one column's text and the next is
identical all the way across.
Right-aligning the numbers was what broke the rhythm: it parks the
ink at the far edge of its column, so the gap before a number is
huge and the gap after it is nothing. The figures stay tabular, so
they still line up within their own column. */}
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
fontSize: 13,
}}
>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '9.5%' }} />
<col style={{ width: '9.5%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '13.5%' }} />
<col style={{ width: '13.5%' }} />
</colgroup>
<thead>
<tr>
<Th>Product</Th>
<Th>Category</Th>
<Th>Unit</Th>
<Th>Price</Th>
<Th>Stock</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
{rows.map((product) => (
<Row
key={product.productid}
product={product}
onOpen={() => onOpen(product)}
onPrice={() => onPrice(product)}
/>
))}
</tbody>
</table>
</div>
</Card>
);
}
/**
* A row that opens on click, anywhere.
*
* The `Open →` link is gone: the whole row is the target now, which is what a
* table of things-you-inspect should be. Keyboard users get the same — the row
* is focusable and answers Enter and Space, because a click handler on a `tr`
* is invisible to anyone not using a mouse.
*
* The price button inside stops propagation, or setting a price would also
* open the drawer behind the dialog.
*/
function Row({
product,
onOpen,
onPrice,
}: {
product: Product;
onOpen: () => void;
onPrice: () => void;
}) {
const [isHovered, setIsHovered] = useState(false);
const state = stateOf(product);
const price = effectivePrice(product);
const stock = stockOf(product);
const unit = [product.unitvalue, product.productunit].filter(Boolean).join(' ');
return (
<tr
onClick={onOpen}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
tabIndex={0}
role="button"
aria-label={`Open ${product.productname ?? 'product'}`}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
style={{
cursor: 'pointer',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
transition: 'background .15s',
}}
>
<Td>
<HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{product.productname ?? '—'}
</Text>
<span
style={{
fontSize: 11.5,
fontFamily: 'var(--font-mono)',
color: 'var(--color-ink-4)',
}}
>
{[product.productbrand, product.productsku].filter(Boolean).join(' · ') || '—'}
</span>
</VStack>
</HStack>
</Td>
<Td isMuted>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{product.categoryname || product.Subcategoryname || '—'}
</span>
</Td>
<Td isMuted>
{unit || '—'}
</Td>
<Td isStrong={price > 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{price > 0 ? money(price) : '—'}
</span>
</Td>
<Td isMuted={stock === 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
</Td>
<Td>
{/* The chip alone. The sentence explaining what blocks the row lives in
the drawer now — a paragraph repeated down every row turned a table
into a wall of prose, and the row already opens on click. */}
<Chip label={STATE_LABEL[state]} color={STATE_COLOR[state]} />
</Td>
<Td>
<HStack gap={1.5} justify="start" align="center" wrap="wrap">
{/* The site's own Button, not a hand-rolled one. Every other action
on this console is an Astryx Button, so its colours, height and
focus ring come from the theme rather than from values retyped
here — which is the only way they stay in step. */}
<Button
label={state === 'unpriced' ? 'Set price' : 'Change price'}
variant={state === 'unpriced' ? 'primary' : 'secondary'}
size="sm"
style={{ height: 26, fontSize: 12 }}
onClick={(event) => {
// The row opens the drawer; this must not do both.
event?.stopPropagation?.();
onPrice();
}}
/>
</HStack>
</Td>
</tr>
);
}
/**
* A 40px pack shot.
*
* `mix-blend-multiply` because catalogue photography is shot on white — without
* it every thumbnail carries a grey square where the photo's ground meets the
* row.
*/
function Thumb({ images, alt }: { images: string[]; alt: string }) {
const [failed, setFailed] = useState(false);
const src = images[0];
return (
<span
style={{
width: 40,
height: 40,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 10,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
overflow: 'hidden',
}}
>
{src && !failed ? (
<img
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
mixBlendMode: 'multiply',
}}
/>
) : (
<ImageOff size={15} style={{ color: 'var(--color-line)' }} />
)}
</span>
);
}
/* ── Bits, matched to the other pages ────────────────────────────────────── */
/** The same status chip Sales uses — soft fill, 26% border, in its own colour. */
function Chip({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
whiteSpace: 'nowrap',
color,
background: `color-mix(in oklab, ${color} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
}}
>
{label}
</span>
);
}
function Th({ children }: { children?: React.ReactNode; }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
isStrong,
}: {
children: React.ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
verticalAlign: 'middle',
}}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,247 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { AlertTriangle, Check } from 'lucide-react';
import { productsApi } from '@/api/products';
import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { effectivePrice } from './productState';
/**
* Set a price and release to the shops.
*
* Two things about this are the backend's shape, not a choice, and both are
* stated on screen rather than hidden:
*
* 1. **It is all-or-nothing across branches.** `PublishProduct` reads the
* tenant's active outlets server-side precisely so a caller cannot narrow
* it: "a console that sent its own list could publish to a subset by
* omission". There is no per-branch publish to offer.
*
* 2. **There is no bulk endpoint.** `publishproduct` takes one scalar
* productid and runs its own transaction. Publishing 500 products is 500
* calls. So the loop is here, with real progress and a per-row result,
* rather than a spinner that pretends one call is happening.
*/
export interface PublishDialogProps {
products: Product[];
tenantid: number;
branchCount: number;
onClose: () => void;
}
type Outcome = { product: Product; ok: boolean; reason?: string };
export function PublishDialog({ products, tenantid, branchCount, onClose }: PublishDialogProps) {
const client = useQueryClient();
const isBulk = products.length > 1;
const first = products[0];
const [price, setPrice] = useState(() => {
const existing = first ? effectivePrice(first) : 0;
return existing > 0 ? String(existing) : '';
});
const [tax, setTax] = useState(() => String(first?.taxpercent ?? 0));
const [done, setDone] = useState(0);
const [running, setRunning] = useState(false);
const [results, setResults] = useState<Outcome[] | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !running) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, running]);
const priceValue = Number(price);
// The backend refuses `price <= 0` with "set a price before publishing — a
// product with no price cannot be sold at a till". Refusing here too means
// the merchant is told before the round trip, not after it.
const priceError =
price.trim() === ''
? null
: !Number.isFinite(priceValue) || priceValue <= 0
? 'A price above zero is required — a till cannot ring up a product at ₹0.'
: null;
const taxValue = Number(tax);
const taxError =
!Number.isFinite(taxValue) || taxValue < 0 ? 'Tax cannot be negative.' : null;
const canSubmit =
!running && price.trim() !== '' && priceError === null && taxError === null;
async function run() {
setRunning(true);
setDone(0);
const outcomes: Outcome[] = [];
// Sequential, not parallel. There is no batch endpoint and each call opens
// its own transaction with a per-outlet loop inside it; firing hundreds at
// a single Go instance to save a few seconds risks a half-published
// catalogue, which is worse than a slow one.
for (const product of products) {
try {
await productsApi.publish({
tenantid,
productid: product.productid,
price: priceValue,
taxpercent: taxValue,
});
outcomes.push({ product, ok: true });
} catch (error) {
outcomes.push({
product,
ok: false,
reason: error instanceof Error ? error.message : 'Failed',
});
}
setDone((value) => value + 1);
}
setResults(outcomes);
setRunning(false);
void client.invalidateQueries({ queryKey: queryKeys.products.all });
}
const failed = results?.filter((entry) => !entry.ok) ?? [];
return (
<>
<div
onClick={running ? undefined : onClose}
style={{ position: 'fixed', inset: 0, zIndex: 60, background: 'rgb(16 24 40 / .35)' }}
/>
<div
role="dialog"
aria-modal
aria-label={isBulk ? 'Price and release products' : 'Price and release product'}
style={{
position: 'fixed',
zIndex: 61,
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 'min(520px, calc(100vw - 32px))',
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
borderRadius: 16,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
boxShadow: '0 24px 48px -12px rgb(16 24 40 / .25)',
}}
>
<VStack gap={2} padding={3}>
<VStack gap={0.5}>
<Text type="label" size="lg" weight="semibold">
{isBulk ? `Price ${products.length} products` : 'Price and release'}
</Text>
<Text type="body" size="sm" color="secondary">
{isBulk
? 'The same price is set on every product selected.'
: (first?.productname ?? '')}
</Text>
</VStack>
{results === null ? (
<>
<TextInput
label="Selling price (₹)"
size="md"
value={price}
onChange={setPrice}
placeholder="0.00"
{...(priceError ? { error: priceError } : {})}
/>
<TextInput
label="Tax %"
size="md"
value={tax}
onChange={setTax}
placeholder="0"
{...(taxError ? { error: taxError } : {})}
/>
{/* The blast radius, stated before the button is pressed. */}
<HStack gap={1.5} align="start">
<span style={{ color: 'var(--color-warning, #b7860b)', flex: 'none', marginTop: 2 }}>
<AlertTriangle size={15} />
</span>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
This sets the price at <strong>all {branchCount} branches</strong> and releases
the product to every one of them. There is no way to price or release a single
branch — publishing is a business-wide decision in this system.
</Text>
</HStack>
{running ? (
<Text type="body" size="sm" color="secondary">
Publishing {done} of {products.length}…
</Text>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} isDisabled={running} />
<Button
label={running ? `Publishing… ${done}/${products.length}` : 'Set price and release'}
variant="primary"
size="sm"
onClick={() => void run()}
isDisabled={!canSubmit}
/>
</HStack>
</>
) : (
<>
<HStack gap={1} align="center">
<span style={{ color: 'var(--color-success, #10b981)' }}>
<Check size={16} />
</span>
<Text type="body" size="sm">
{results.length - failed.length} of {results.length} released to all{' '}
{branchCount} branches.
</Text>
</HStack>
{failed.length > 0 ? (
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{failed.length} could not be published
</Text>
{failed.map((entry) => (
<Text
key={entry.product.productid}
type="body"
size="xsm"
color="secondary"
>
{entry.product.productname} — {entry.reason}
</Text>
))}
</VStack>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
{failed.length > 0 ? (
<Button
label="Retry the failures"
variant="ghost"
size="sm"
onClick={() => {
setResults(null);
setDone(0);
}}
/>
) : null}
<Button label="Done" variant="primary" size="sm" onClick={onClose} />
</HStack>
</>
)}
</VStack>
</div>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { Drawer } from './Drawer';
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
/**
* Upload a product spreadsheet, from the Products tab.
*
* The panel inside is the one already built for Nearle Admin — reused rather
* than rewritten, because the awkward part is not the UI. There is no batch
* create in Fiesta: `POST /products/create` takes ONE product and does not
* return the id it generated, so an import is N creates, then a lookup by SKU
* to resolve the ids, then one batched location call and one batched stock
* call. The panel stages that so a row the server rejects fails alone.
*
* It is not idempotent and cannot be made so from here — nothing on the server
* dedupes on SKU — so the panel says that before the button rather than after.
*/
export function SheetUploadDrawer({
tenantid,
locationid,
onClose,
}: {
tenantid: number | undefined;
locationid: number | undefined;
onClose: () => void;
}) {
return (
<Drawer
title="Upload a product sheet"
subtitle="Your own product list, checked before anything is created."
width={560}
onClose={onClose}
>
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
</Drawer>
);
}

View File

@@ -52,7 +52,7 @@ export function share(part: number | undefined, total: number | undefined): numb
}
export const percent = (value: number | null): string =>
value === null ? '—' : `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
value === null ? '—' : value === 0 ? '0%' : `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
/** A short local time — "14:32". Used on bills and last-sync stamps. */
export const clock = (date: Date | null): string =>

View File

@@ -0,0 +1,464 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { FileInput } from '@astryxdesign/core/FileInput';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import * as XLSX from 'xlsx';
import { Download, Upload } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { offlineSalesApi, type OfflineSalesUploadResponse } from '@/api/offlineSales';
import { useAuth } from '@/auth/AuthContext';
import { count, money } from '../format';
import { Drawer } from '../Drawer';
import {
buildTemplateWorkbook,
parseSalesWorkbook,
summarise,
templateFilename,
toBills,
type ParsedSheet,
} from './salesSheet';
/**
* Counter sales, from a spreadsheet.
*
* Three steps, in the order the person does them: download the workbook, fill
* in what sold, upload it back. The middle step happens in Excel, so the drawer
* has to survive being left open for an hour — which is why nothing here is
* timed and the template refetches on open rather than being cached.
*
* The preview between upload and import is the point of the whole screen: an
* import moves stock, and a row that would move the wrong stock has to be
* visible before it does, not reported afterwards.
*/
export function CounterSalesDrawer({
tenantid,
/** Set for a Store user — pins the upload to their branch. */
locationid,
branchIds,
onClose,
}: {
tenantid: number;
locationid?: number;
branchIds: number[];
onClose: () => void;
}) {
const { user } = useAuth();
const client = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
const [readError, setReadError] = useState<string | null>(null);
const [result, setResult] = useState<OfflineSalesUploadResponse | null>(null);
/**
* `staleTime: 0` on purpose. A template is only useful if its stock figures
* and product list match the shops right now — an hour-old one sends someone
* to fill in a row for a product that has since been withdrawn.
*/
const template = useQuery({
queryKey: ['saleTemplate', tenantid, locationid ?? 0],
queryFn: () => offlineSalesApi.template(tenantid, locationid),
staleTime: 0,
});
const rows = parsed?.rows ?? [];
const totals = useMemo(() => summarise(rows), [rows]);
const isBlocked = (parsed?.fatal.length ?? 0) > 0 || totals.errors > 0;
async function onFile(next: File | null) {
setFile(next);
setParsed(null);
setReadError(null);
if (!next) return;
try {
const buffer = await next.arrayBuffer();
setParsed(
parseSalesWorkbook(buffer, {
tenantid,
...(locationid ? { locationid } : {}),
allowedLocationIds: branchIds,
}),
);
} catch {
setReadError('That file could not be read. Upload the .xlsx template you downloaded.');
}
}
function download() {
if (!template.data) return;
const today = new Date().toISOString().slice(0, 10);
XLSX.writeFile(buildTemplateWorkbook(template.data), templateFilename(template.data, today));
}
const upload = useMutation({
mutationFn: () =>
offlineSalesApi.upload({
tenantid,
// Sent even when it is 0. At 0 each bill goes to the branch its own row
// names; set, it refuses every bill naming another branch — the one
// server-side guard that holds a shop to its own store.
locationid: locationid ?? 0,
userid: user?.userid ?? 0,
bills: toBills(rows),
}),
onSuccess: async (response) => {
setResult(response);
// An import moves stock and creates orders, so everything counting either
// is stale. Cheaper to refetch broadly than to reason about which page
// the operator opens next.
await client.invalidateQueries();
},
});
if (result) {
return (
<Drawer title="Import finished" width={620} onClose={onClose}>
<div className="form-grid">
<Figure label="Imported" value={count(result.imported)} isStrong />
<Figure label="Already done" value={count(result.duplicate)} />
<Figure label="Failed" value={count(result.failed)} />
<Figure label="Recorded" value={money(result.totalamount)} />
</div>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{result.imported > 0
? 'Stock has come off the shelf and the sales are in Orders, marked offline.'
: 'Nothing was imported, so no stock moved.'}
{result.duplicate > 0
? ' Bills marked “already done” were uploaded before — their stock was not deducted twice.'
: ''}
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Store</Th>
<Th>Bill</Th>
<Th>Result</Th>
<Th>Items</Th>
<Th>Amount</Th>
</tr>
</thead>
<tbody>
{result.results.map((entry, index) => (
<tr key={`${entry.locationid}-${entry.billno}-${index}`}>
<Td>{entry.locationname || `Branch ${entry.locationid}`}</Td>
<Td>{entry.billno || '—'}</Td>
<Td>
<ResultChip status={entry.status} message={entry.message} />
</Td>
<Td>{count(entry.itemcount ?? 0)}</Td>
<Td>{money(entry.amount ?? 0)}</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<HStack gap={1} justify="end">
<Button
label="Upload another file"
variant="secondary"
size="sm"
onClick={() => {
setResult(null);
setParsed(null);
setFile(null);
}}
/>
<Button label="Done" variant="primary" size="sm" onClick={onClose} />
</HStack>
</Drawer>
);
}
return (
<Drawer
title="Upload counter sales"
subtitle={
locationid
? 'Sales rung up at your counter, so the stock comes off the shelf.'
: 'One file covers every branch — each row says which store it belongs to.'
}
width={720}
onClose={onClose}
>
{/* ── 1. The workbook ────────────────────────────────────────────── */}
<VStack gap={1}>
<Step number={1} title="Download the workbook" />
<HStack gap={1.5} align="center" wrap="wrap">
<Button
label={template.isLoading ? 'Preparing…' : 'Download template'}
variant="secondary"
size="sm"
icon={<Download size={13} />}
isDisabled={!template.data || template.isLoading}
onClick={download}
/>
{template.data ? (
<Text type="body" size="sm" color="secondary">
{count(template.data.products.length)} products ·{' '}
{count(template.data.locations.length)} store
{template.data.locations.length === 1 ? '' : 's'}
</Text>
) : null}
</HStack>
{template.isError ? (
<Problem message={errorMessage(template.error)} />
) : (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
Fill in the <strong>qtysold</strong> column and upload the file back. It has to be this
workbook — the product ids are already in it, and nothing else identifies a product.
</Text>
)}
</VStack>
{/* ── 2. The filled-in file ──────────────────────────────────────── */}
<VStack gap={1}>
<Step number={2} title="Upload it back" />
<FileInput
label="Filled-in workbook"
isLabelHidden
value={file}
onChange={(next) => void onFile(Array.isArray(next) ? (next[0] ?? null) : next)}
accept=".xlsx,.xls"
mode="dropzone"
placeholder="Drop the .xlsx file here, or click to choose"
/>
{readError ? <Problem message={readError} /> : null}
{parsed?.fatal.map((message) => <Problem key={message} message={message} />)}
</VStack>
{/* ── 3. What is in it ───────────────────────────────────────────── */}
{parsed && parsed.fatal.length === 0 ? (
<VStack gap={1.5}>
<Step number={3} title="Check and import" />
<div className="form-grid">
<Figure label="Bills" value={count(totals.bills)} isStrong />
<Figure label="Lines" value={count(totals.lines)} />
<Figure label="Units" value={count(totals.units)} />
<Figure label="Amount" value={money(totals.amount)} isStrong />
</div>
{totals.errors > 0 ? (
<Problem
message={`${count(totals.errors)} row${totals.errors === 1 ? '' : 's'} must be fixed in the file before this can be uploaded.`}
/>
) : null}
{parsed.skipped > 0 ? (
<Text type="body" size="sm" color="secondary">
{count(parsed.skipped)} row{parsed.skipped === 1 ? '' : 's'} had no quantity and were
ignored.
</Text>
) : null}
<Card padding={0} elevation="low">
<div className="table-scroll" style={{ maxHeight: 320, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th>Row</Th>
{locationid ? null : <Th>Store</Th>}
<Th>Product</Th>
<Th>Qty</Th>
<Th>Price</Th>
<Th>Bill</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.excelRow}>
<Td isMuted>{row.excelRow}</Td>
{locationid ? null : (
<Td isMuted>{row.locationname || row.locationid}</Td>
)}
<Td>{row.productname || `#${row.productid}`}</Td>
<Td>{row.qtysold}</Td>
<Td>{row.unitprice ? money(row.unitprice) : '—'}</Td>
<Td isMuted>{row.billno || '—'}</Td>
<Td>
<RowStatus errors={row.errors} warnings={row.warnings} />
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{upload.isError ? <Problem message={errorMessage(upload.error)} /> : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button
label="Clear"
variant="ghost"
size="sm"
onClick={() => {
setParsed(null);
setFile(null);
}}
/>
<Button
label={upload.isPending ? 'Importing…' : `Import ${count(totals.bills)} bills`}
variant="primary"
size="sm"
icon={<Upload size={13} />}
isDisabled={isBlocked || upload.isPending || rows.length === 0}
onClick={() => upload.mutate()}
/>
</HStack>
</VStack>
) : null}
</Drawer>
);
}
/* ── Bits ────────────────────────────────────────────────────────────────── */
function Step({ number, title }: { number: number; title: string }) {
return (
<HStack gap={1} align="center">
<span
style={{
width: 20,
height: 20,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
fontSize: 11,
fontWeight: 700,
}}
>
{number}
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
</HStack>
);
}
function Figure({ label, value, isStrong }: { label: string; value: string; isStrong?: boolean }) {
return (
<VStack gap={0}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text
type="label"
size={isStrong ? 'lg' : 'sm'}
weight="semibold"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Text>
</VStack>
);
}
function RowStatus({ errors, warnings }: { errors: string[]; warnings: string[] }) {
if (errors.length > 0) {
return (
<span style={{ color: 'var(--color-error, #d64545)', fontSize: 12 }}>{errors.join(' · ')}</span>
);
}
if (warnings.length > 0) {
return (
<span style={{ color: 'var(--color-warning, #b7860b)', fontSize: 12 }}>
{warnings.join(' · ')}
</span>
);
}
return <span style={{ color: 'var(--color-success, #10b981)', fontSize: 12 }}>ready</span>;
}
/**
* A duplicate is neutral on purpose.
*
* A re-upload being refused is the safeguard working. Calling it an error would
* push someone towards "fixing" it, and the fix they would reach for is
* changing the bill number — which is exactly how stock gets deducted twice.
*/
function ResultChip({ status, message }: { status: string; message?: string }) {
const value = status.toLowerCase();
const color =
value === 'imported'
? 'var(--color-success, #10b981)'
: value === 'duplicate'
? 'var(--color-ink-3)'
: 'var(--color-error, #d64545)';
const label = value === 'duplicate' ? 'already done' : value;
return (
<VStack gap={0}>
<span style={{ color, fontSize: 12, fontWeight: 600 }}>{label}</span>
{message ? (
<span style={{ color: 'var(--color-ink-4)', fontSize: 11.5 }}>{message}</span>
) : null}
</VStack>
);
}
function Problem({ message }: { message: string }) {
return (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)', lineHeight: 1.6 }}>
{message}
</Text>
);
}
function Th({ children }: { children?: React.ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
function Td({
children,
isMuted,
}: {
children: React.ReactNode;
isMuted?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
verticalAlign: 'middle',
}}
>
{children}
</td>
);
}

View File

@@ -0,0 +1,163 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import * as XLSX from 'xlsx';
import type { SaleTemplate } from '@/api/offlineSales';
import {
buildTemplateWorkbook,
parseSalesWorkbook,
summarise,
templateFilename,
toBills,
} from './salesSheet';
const TEMPLATE: SaleTemplate = {
tenantid: 1135,
locationid: 0,
locations: [
{ locationid: 1, locationname: 'R mart — RS Puram', productcount: 2 },
{ locationid: 2, locationname: 'R mart — Peelamedu', productcount: 1 },
],
products: [
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9001, productname: 'Aachi Chilli Powder 100gm', currentstock: 24, price: 35, taxpercent: 5 },
{ tenantid: 1135, locationid: 1, locationname: 'R mart — RS Puram', productid: 9004, productname: 'Amul Butter 500g', currentstock: 2, price: 265, taxpercent: 12 },
{ tenantid: 1135, locationid: 2, locationname: 'R mart — Peelamedu', productid: 9007, productname: 'Nestle Milkmaid 400g', currentstock: 40, price: 160, taxpercent: 12 },
],
};
/** Round-trip helper: build the workbook, fill in cells, hand back a buffer. */
function filled(edits: Record<number, Record<string, string | number>>): ArrayBuffer {
const workbook = buildTemplateWorkbook(TEMPLATE);
const sheet = workbook.Sheets['Sales'] as XLSX.WorkSheet;
const header = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1 })[0] as string[];
const column = (name: string) =>
header.findIndex((cell) => String(cell).toLowerCase().startsWith(name));
for (const [row, values] of Object.entries(edits)) {
for (const [name, value] of Object.entries(values)) {
const address = XLSX.utils.encode_cell({ r: Number(row) - 1, c: column(name) });
sheet[address] = typeof value === 'number' ? { t: 'n', v: value } : { t: 's', v: value };
}
}
const out = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
return out;
}
test('the workbook carries every product, ids filled in', () => {
const workbook = buildTemplateWorkbook(TEMPLATE);
assert.deepEqual(workbook.SheetNames, ['Sales', 'Store Info', 'Instructions']);
const rows = XLSX.utils.sheet_to_json<unknown[]>(workbook.Sheets['Sales'] as XLSX.WorkSheet, {
header: 1,
});
assert.equal(rows.length, 4, 'header plus three products');
assert.equal((rows[1] as unknown[])[3], 9001, 'productid is pre-filled');
assert.equal((rows[1] as unknown[])[6], '', 'qtysold is left empty, never defaulted to 0');
});
test('the filename says what the workbook spans', () => {
assert.equal(templateFilename(TEMPLATE, '2026-08-25'), 'counter-sales-all-stores-2026-08-25.xlsx');
assert.equal(
templateFilename({ ...TEMPLATE, locations: [TEMPLATE.locations[0] as never] }, '2026-08-25'),
'counter-sales-r-mart-rs-puram-2026-08-25.xlsx',
);
});
test('rows with no quantity are skipped, not reported as problems', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 3 } }), { tenantid: 1135 });
assert.deepEqual(parsed.fatal, []);
assert.equal(parsed.rows.length, 1);
assert.equal(parsed.skipped, 2);
assert.equal(parsed.rows[0]?.productid, 9001);
});
test('a file from another account is refused whole', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 } }), { tenantid: 9999 });
assert.ok(parsed.fatal[0]?.includes('different account'));
});
test('selling more than the shelf holds is an error on that row', () => {
const parsed = parseSalesWorkbook(filled({ 3: { qtysold: 5 } }), { tenantid: 1135 });
assert.equal(parsed.rows.length, 1);
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('only 2 in stock')));
});
test('a pinned upload refuses rows belonging to another branch', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1 }, 4: { qtysold: 1 } }), {
tenantid: 1135,
locationid: 1,
});
const foreign = parsed.rows.find((row) => row.locationid === 2);
assert.ok(foreign, 'the row is still parsed, so the operator can see it');
assert.ok(foreign?.errors.some((problem) => problem.includes('another store')));
const own = parsed.rows.find((row) => row.locationid === 1);
assert.deepEqual(own?.errors, []);
});
test('an unpriced line is a warning, not a blocker', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, unitprice: 0 } }), {
tenantid: 1135,
});
assert.deepEqual(parsed.rows[0]?.errors, []);
assert.ok(parsed.rows[0]?.warnings.some((note) => note.includes('no price')));
});
test('an unknown payment mode is caught before upload', () => {
const parsed = parseSalesWorkbook(filled({ 2: { qtysold: 1, paymentmode: 'cheque' } }), {
tenantid: 1135,
});
assert.ok(parsed.rows[0]?.errors.some((problem) => problem.includes('not Cash, Card or UPI')));
});
test('the same bill number at two branches stays two bills', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 1, billno: '1' }, 4: { qtysold: 1, billno: '1' } }),
{ tenantid: 1135 },
);
const bills = toBills(parsed.rows);
assert.equal(bills.length, 2);
assert.deepEqual(
bills.map((bill) => bill.locationid).sort(),
[1, 2],
);
});
test('rows sharing a bill number become one bill with its lines', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 2, billno: 'A-7' }, 3: { qtysold: 1, billno: 'A-7' } }),
{ tenantid: 1135 },
);
const bills = toBills(parsed.rows);
assert.equal(bills.length, 1);
assert.equal(bills[0]?.items.length, 2);
assert.equal(bills[0]?.billno, 'A-7');
});
test('bill-level fields are taken from whichever row carries them', () => {
const parsed = parseSalesWorkbook(
filled({
2: { qtysold: 1, billno: 'B-2' },
3: { qtysold: 1, billno: 'B-2', paymentmode: 'UPI', customername: 'Kavitha' },
}),
{ tenantid: 1135 },
);
const bill = toBills(parsed.rows)[0];
assert.equal(bill?.paymentmode, 'UPI');
assert.equal(bill?.customername, 'Kavitha');
});
test('the preview total is unit price by quantity, less discount', () => {
const parsed = parseSalesWorkbook(
filled({ 2: { qtysold: 4, discountamount: 20 }, 4: { qtysold: 2 } }),
{ tenantid: 1135 },
);
const totals = summarise(parsed.rows);
assert.equal(totals.lines, 2);
assert.equal(totals.units, 6);
assert.equal(totals.amount, 35 * 4 - 20 + 160 * 2);
assert.equal(totals.stores, 2);
assert.equal(totals.bills, 2, 'no bill number means one bill per branch');
});
test('a file with nothing filled in says so rather than uploading nothing', () => {
const parsed = parseSalesWorkbook(filled({}), { tenantid: 1135 });
assert.ok(parsed.fatal[0]?.includes('No sold quantities'));
});

View File

@@ -0,0 +1,555 @@
import * as XLSX from 'xlsx';
import type {
OfflineSaleBillInput,
SaleTemplate,
SaleTemplateRow,
} from '@/api/offlineSales';
/**
* The spreadsheet half of counter-sales import: turning a merchant's catalogue
* into a workbook the shops fill in, and turning that workbook back into bills
* the API will take.
*
* **One workbook covers every branch.** Each row carries its own `tenantid` and
* `locationid`, and that row's `locationid` decides which branch the sale comes
* out of. A merchant running six outlets downloads one file; rows for all six
* can be filled in and uploaded together. Nobody picks a store in the UI,
* because the sheet already says which store each line is for.
*
* **The template must be downloaded, not typed.** `productid` is the only field
* that identifies a product: across the live catalogue 6,245 products share 93
* distinct SKU values — one tenant has 463 products all carrying sku "1" — and
* names are not unique either. So the sheet is generated with the ids already
* filled in, and the parser matches on nothing else.
*
* Parsing happens in the browser so the operator sees every problem laid out
* against their own rows and can fix the file before anything is written. The
* backend re-validates all of it; this is for the person, not for safety.
*/
/** Parsing looks for this sheet by name, then falls back to the first one, so a
* file re-saved by Excel under a translated name still works. */
export const SALES_SHEET = 'Sales';
export const INFO_SHEET = 'Store Info';
const HELP_SHEET = 'Instructions';
/** Bumped only when the column set changes in a way an old file would break on. */
export const TEMPLATE_VERSION = 2;
/**
* The columns, in order. The first six are locked reference data —
* `locationid` among them, since it routes the sale — and `qtysold` is the one
* the operator is expected to type in.
*/
const COLUMNS = [
'tenantid',
'locationid',
'locationname',
'productid',
'productname',
'currentstock',
'qtysold',
'unitprice',
'discountamount',
'taxpercent',
'billno',
'saledate',
'paymentmode',
'customername',
'customermobile',
'remarks',
] as const;
const COLUMN_WIDTHS = [10, 11, 22, 11, 38, 13, 10, 11, 15, 11, 14, 13, 13, 18, 15, 24];
const HEADER_LABELS: Record<string, string> = {
tenantid: 'tenantid (do not edit)',
locationid: 'locationid (do not edit)',
locationname: 'store (do not edit)',
productid: 'productid (do not edit)',
productname: 'productname (do not edit)',
currentstock: 'currentstock (info)',
qtysold: 'qtysold *',
unitprice: 'unitprice',
discountamount: 'discountamount',
taxpercent: 'taxpercent',
billno: 'billno',
saledate: 'saledate',
paymentmode: 'paymentmode',
customername: 'customername',
customermobile: 'customermobile',
remarks: 'remarks',
};
const INSTRUCTIONS: (string | number)[][] = [
['How to record counter sales'],
[''],
['This ONE file covers every one of your stores.'],
['Each row already says which store it belongs to, in the locationid and store columns.'],
[''],
['1.', 'Fill in the "qtysold" column for whatever was sold at the counter.'],
['', 'Leave the row blank or 0 if it did not sell — blank rows are ignored.'],
['2.', 'Do NOT edit tenantid, locationid, store, productid or productname.'],
['', 'They identify the store and the product, and must match.'],
['3.', 'unitprice defaults to that store price. Change it if you sold at a different price.'],
['4.', 'billno groups rows into one bill. Rows sharing a billno become one order.'],
['', 'Bill numbers only need to be unique within a store.'],
['5.', 'saledate accepts YYYY-MM-DD. Blank means today.'],
['6.', 'paymentmode accepts Cash, Card or UPI. Blank means Cash.'],
['7.', 'taxpercent is treated as already included in unitprice, so the amount collected'],
['', 'stays exactly unitprice x qtysold minus any discount.'],
[''],
['Uploading the same file twice is safe — a repeated bill is reported as already'],
['imported and its stock is NOT deducted a second time.'],
];
/**
* Build the workbook.
*
* Every stocked product gets a row, even at zero stock: this is a worksheet to
* fill in, and hiding a row would mean an operator could not record a sale they
* actually made.
*/
export function buildTemplateWorkbook(template: SaleTemplate): XLSX.WorkBook {
const header = COLUMNS.map((column) => HEADER_LABELS[column] ?? column);
const body = template.products.map((product: SaleTemplateRow) => [
product.tenantid,
product.locationid,
product.locationname,
product.productid,
product.productname,
product.currentstock,
// qtysold onward are the operator's columns, left empty: pre-filling
// qtysold with 0 invites a file of accidental zero-quantity rows.
'',
product.price > 0 ? product.price : '',
'',
product.taxpercent > 0 ? product.taxpercent : '',
'',
'',
'',
'',
'',
'',
]);
const sales = XLSX.utils.aoa_to_sheet([header, ...body]);
sales['!cols'] = COLUMN_WIDTHS.map((width) => ({ wch: width }));
sales['!autofilter'] = {
ref: XLSX.utils.encode_range({
s: { r: 0, c: 0 },
e: { r: body.length, c: COLUMNS.length - 1 },
}),
};
const info = XLSX.utils.aoa_to_sheet([
['Field', 'Value'],
['tenantid', template.tenantid],
['templateversion', TEMPLATE_VERSION],
['stores in this file', template.locations.length],
[''],
['Stores covered', 'Products'],
...template.locations.map((location) => [
`${location.locationid} — ${location.locationname}`,
location.productcount,
]),
[''],
['Do not edit this sheet.'],
]);
info['!cols'] = [{ wch: 34 }, { wch: 42 }];
const help = XLSX.utils.aoa_to_sheet(INSTRUCTIONS);
help['!cols'] = [{ wch: 4 }, { wch: 92 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sales, SALES_SHEET);
XLSX.utils.book_append_sheet(workbook, info, INFO_SHEET);
XLSX.utils.book_append_sheet(workbook, help, HELP_SHEET);
return workbook;
}
/** One store by name, or "all-stores", plus the date so a folder stays sortable. */
export function templateFilename(template: SaleTemplate, today: string): string {
const single = template.locations.length === 1 ? (template.locations[0]?.locationname ?? '') : '';
const slug =
(single || 'all-stores')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'all-stores';
return `counter-sales-${slug}-${today}.xlsx`;
}
/* ── Parsing ─────────────────────────────────────────────────────────────── */
export interface ParsedSaleRow {
/** 1-based row number as Excel shows it, so a problem can be pointed at. */
excelRow: number;
tenantid: number | null;
locationid: number;
locationname: string;
productid: number;
productname: string;
currentstock: number | null;
qtysold: number;
unitprice: number | null;
discountamount: number;
taxpercent: number | null;
billno: string;
saledate: string;
paymentmode: string;
customername: string;
customermobile: string;
remarks: string;
errors: string[];
warnings: string[];
}
export interface ParsedSheet {
tenantid: number | null;
/** Rows with a quantity. Blank ones are dropped, not reported as problems. */
rows: ParsedSaleRow[];
/** Rows skipped for having no quantity — counted so "the column was empty"
* can be told apart from "the file had two sales in it". */
skipped: number;
/** Problems with the file as a whole rather than with a row. */
fatal: string[];
}
export interface ParseScope {
tenantid: number;
/** Set for a Store user: rows for any other branch are refused. */
locationid?: number;
/** Branches the uploader may write to, for naming a stray id usefully. */
allowedLocationIds?: number[];
}
const PAYMENT_MODES = new Set(['cash', 'card', 'upi']);
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
const parsed = Number(String(value).trim().replace(/,/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
function toText(value: unknown): string {
if (value === null || value === undefined) return '';
return String(value).trim();
}
/**
* A date cell arrives as a real Date, a serial number, or text depending on how
* the operator's Excel was configured. All three become YYYY-MM-DD so the
* backend sees one format.
*/
function toDate(value: unknown): string {
if (value === null || value === undefined || value === '') return '';
if (value instanceof Date) return value.toISOString().slice(0, 10);
if (typeof value === 'number') {
const parsed = XLSX.SSF?.parse_date_code?.(value);
if (parsed && parsed.y) {
return `${parsed.y}-${String(parsed.m).padStart(2, '0')}-${String(parsed.d).padStart(2, '0')}`;
}
return '';
}
return String(value).trim();
}
/** Match a header cell back to a column, tolerating "(do not edit)" and "*". */
function normaliseHeader(raw: unknown): string {
return toText(raw)
.toLowerCase()
.replace(/\(.*?\)/g, '')
.replace(/[^a-z]/g, '');
}
/**
* Parse an uploaded workbook.
*
* Never throws for a row-level problem — those are attached to the row so the
* whole sheet can be shown at once, which is the point of parsing here. Only a
* file that cannot be read, or has no recognisable columns, is fatal.
*/
export function parseSalesWorkbook(data: ArrayBuffer, scope: ParseScope): ParsedSheet {
const out: ParsedSheet = { tenantid: null, rows: [], skipped: 0, fatal: [] };
let workbook: XLSX.WorkBook;
try {
workbook = XLSX.read(data, { cellDates: true });
} catch {
out.fatal.push('That file could not be read as a spreadsheet. Upload the .xlsx template.');
return out;
}
const infoSheet = workbook.Sheets[INFO_SHEET];
if (infoSheet) {
const infoRows = XLSX.utils.sheet_to_json<unknown[]>(infoSheet, {
header: 1,
blankrows: false,
});
for (const row of infoRows) {
if (toText(row?.[0]).toLowerCase() === 'tenantid') out.tenantid = toNumber(row?.[1]);
}
}
if (out.tenantid !== null && out.tenantid !== scope.tenantid) {
out.fatal.push(
`This file was generated for a different account (tenant ${out.tenantid}). Download a fresh template.`,
);
}
const firstName = workbook.SheetNames[0];
const sheet = workbook.Sheets[SALES_SHEET] ?? (firstName ? workbook.Sheets[firstName] : undefined);
if (!sheet) {
out.fatal.push('The workbook has no sheets.');
return out;
}
const grid = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
blankrows: false,
defval: '',
});
if (grid.length < 2) {
out.fatal.push('The Sales sheet has no rows to import.');
return out;
}
const index: Record<string, number> = {};
(grid[0] ?? []).forEach((cellValue, position) => {
const key = normaliseHeader(cellValue);
if (key && index[key] === undefined) index[key] = position;
});
if (index['productid'] === undefined || index['qtysold'] === undefined) {
out.fatal.push(
'The Sales sheet is missing the "productid" or "qtysold" column. Upload the downloaded template without renaming its columns.',
);
return out;
}
if (index['locationid'] === undefined && !scope.locationid) {
// Without it there is nothing to route a sale by, and guessing a branch
// would silently move the wrong store's stock.
out.fatal.push(
'The Sales sheet is missing the "locationid" column, so there is no way to tell which store each sale belongs to. Download a fresh template.',
);
return out;
}
const allowed = scope.allowedLocationIds?.length ? new Set(scope.allowedLocationIds) : null;
const cell = (row: unknown[], key: string): unknown => {
const position = index[key];
return position === undefined ? '' : row[position];
};
for (let i = 1; i < grid.length; i += 1) {
const raw = grid[i] ?? [];
const excelRow = i + 1;
const qty = toNumber(cell(raw, 'qtysold'));
// Nothing sold on this line. Not an error — the template lists an entire
// catalogue and most rows are expected to be empty.
if (qty === null || qty === 0) {
out.skipped += 1;
continue;
}
const productid = toNumber(cell(raw, 'productid'));
const rowLocation = toNumber(cell(raw, 'locationid'));
const unitprice = toNumber(cell(raw, 'unitprice'));
const taxpercent = toNumber(cell(raw, 'taxpercent'));
const discount = toNumber(cell(raw, 'discountamount')) ?? 0;
const stock = toNumber(cell(raw, 'currentstock'));
const paymentmode = toText(cell(raw, 'paymentmode'));
const locationid = rowLocation ?? scope.locationid ?? 0;
const row: ParsedSaleRow = {
excelRow,
tenantid: toNumber(cell(raw, 'tenantid')),
locationid,
locationname: toText(cell(raw, 'locationname')),
productid: productid ?? 0,
productname: toText(cell(raw, 'productname')),
currentstock: stock,
qtysold: qty,
unitprice,
discountamount: discount,
taxpercent,
billno: toText(cell(raw, 'billno')),
saledate: toDate(cell(raw, 'saledate')),
paymentmode,
customername: toText(cell(raw, 'customername')),
customermobile: toText(cell(raw, 'customermobile')),
remarks: toText(cell(raw, 'remarks')),
errors: [],
warnings: [],
};
if (!productid || productid <= 0) {
row.errors.push('productid is missing — do not delete that column');
}
if (!locationid || locationid <= 0) {
row.errors.push('locationid is missing — this row does not say which store it belongs to');
} else if (row.tenantid !== null && row.tenantid !== scope.tenantid) {
row.errors.push(`tenantid ${row.tenantid} is not your account`);
} else if (scope.locationid && locationid !== scope.locationid) {
// The Store user guard. The backend enforces it too; saying it here means
// they see it before uploading rather than as a rejected bill.
row.errors.push('this row is for another store, which you cannot upload for');
} else if (allowed && !allowed.has(locationid)) {
row.errors.push(`locationid ${locationid} is not one of your stores`);
}
if (qty < 0) row.errors.push('qtysold cannot be negative');
if (stock !== null && qty > stock) {
row.errors.push(`only ${stock} in stock, ${qty} sold`);
}
if (discount < 0) row.errors.push('discountamount cannot be negative');
if (unitprice !== null && unitprice > 0 && discount > unitprice * qty) {
row.errors.push('discount is larger than the line total');
}
if (paymentmode && !PAYMENT_MODES.has(paymentmode.toLowerCase())) {
row.errors.push(`paymentmode "${paymentmode}" is not Cash, Card or UPI`);
}
// Warnings do not block. A zero price is legitimate — a free sample — but
// is nearly always a forgotten cell, so it is worth saying.
if (unitprice === null || unitprice <= 0) {
row.warnings.push('no price — this sale will record no revenue');
}
if (!Number.isInteger(qty)) row.warnings.push('fractional quantity');
out.rows.push(row);
}
if (out.rows.length === 0 && out.fatal.length === 0) {
out.fatal.push('No sold quantities found. Fill in the "qtysold" column for at least one row.');
}
return out;
}
/**
* Group rows into bills.
*
* Keyed on BRANCH first and bill number second, so the same bill number at two
* stores stays two sales rather than colliding — counter books at different
* outlets routinely restart numbering from 1.
*
* Rows with no bill number collapse into one unnumbered bill per branch rather
* than one bill per row: a sheet where the operator ignored the column is one
* shopping trip far more often than fifty, and one bill per row would also mean
* one order per row cluttering the order list.
*/
export function toBills(rows: ParsedSaleRow[]): OfflineSaleBillInput[] {
const groups = new Map<string, ParsedSaleRow[]>();
for (const row of rows) {
const key = billKey(row);
const bucket = groups.get(key);
if (bucket) bucket.push(row);
else groups.set(key, [row]);
}
return [...groups.values()].map((group) => {
const head = group[0] as ParsedSaleRow;
// Bill-level fields belong to the bill, not the line: taking the first
// non-empty value means the operator fills them in on the first row only,
// which is how people actually fill these in.
const first = (pick: (row: ParsedSaleRow) => string): string =>
group.map(pick).find((value) => value !== '') ?? '';
return {
locationid: head.locationid,
billno: head.billno.trim(),
saledate: first((row) => row.saledate),
paymentmode: first((row) => row.paymentmode),
customername: first((row) => row.customername),
customermobile: first((row) => row.customermobile),
remarks: first((row) => row.remarks),
items: group.map((row) => ({
productid: row.productid,
productname: row.productname || undefined,
qtysold: row.qtysold,
unitprice: row.unitprice ?? undefined,
discountamount: row.discountamount || undefined,
taxpercent: row.taxpercent ?? undefined,
})),
};
});
}
function billKey(row: ParsedSaleRow): string {
return `${row.locationid}::${row.billno.trim().toUpperCase() || '__nobill__'}`;
}
export interface SaleSummary {
lines: number;
units: number;
amount: number;
errors: number;
warnings: number;
bills: number;
stores: number;
byStore: {
locationid: number;
locationname: string;
lines: number;
units: number;
amount: number;
errors: number;
}[];
}
/**
* Totals for the preview.
*
* The arithmetic mirrors the backend's — tax inclusive — so the figure shown
* before the upload is the figure that gets recorded.
*/
export function summarise(rows: ParsedSaleRow[]): SaleSummary {
let units = 0;
let amount = 0;
let errors = 0;
let warnings = 0;
const bills = new Set<string>();
const stores = new Map<number, SaleSummary['byStore'][number]>();
for (const row of rows) {
const lineAmount = Math.max(0, (row.unitprice ?? 0) * row.qtysold - row.discountamount);
units += row.qtysold;
amount += lineAmount;
if (row.errors.length) errors += 1;
if (row.warnings.length) warnings += 1;
bills.add(billKey(row));
let store = stores.get(row.locationid);
if (!store) {
store = {
locationid: row.locationid,
locationname: row.locationname,
lines: 0,
units: 0,
amount: 0,
errors: 0,
};
stores.set(row.locationid, store);
}
store.lines += 1;
store.units += row.qtysold;
store.amount += lineAmount;
if (row.errors.length) store.errors += 1;
if (!store.locationname && row.locationname) store.locationname = row.locationname;
}
return {
lines: rows.length,
units,
amount,
errors,
warnings,
bills: bills.size,
stores: stores.size,
byStore: [...stores.values()].sort((a, b) => b.amount - a.amount),
};
}

View File

@@ -10,7 +10,6 @@ import {
AlertTriangle,
ArrowRight,
ClipboardList,
Info,
Monitor,
PackageX,
Receipt,
@@ -51,7 +50,16 @@ import {
* rather than an omission. See `store-admin-backend-gap.md` §3.1.
*/
export function ConsolePage() {
const { branches, scoped, selected, current, tenantid, isLoading } = useBranchScope();
const { branches, scoped, selected, current, tenantid, isLoading, isPinned } = useBranchScope();
/**
* Where this page's links point.
*
* The page is shared with the Store user workspace, pinned to one branch, and
* a hardcoded `/admin/...` there sends a shop to a workspace its role cannot
* enter — which the route guard answers with a redirect, so the alert silently
* does nothing.
*/
const base = isPinned ? '/store' : '/admin';
const [preset, setPreset] = useState<RangePreset>('today');
const [range, setRange] = useState<DateRange>(() => presetRange('today'));
@@ -116,7 +124,7 @@ export function ConsolePage() {
[rows],
);
const alerts = useMemo(() => buildAlerts(rows), [rows]);
const alerts = useMemo(() => buildAlerts(rows, base, isPinned), [rows, base, isPinned]);
const cancelShare = share(totals.cancelled, totals.onlineOrders);
return (
@@ -145,17 +153,7 @@ export function ConsolePage() {
{/* ── Revenue, by channel, never summed ──────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title="Revenue"
note="two ledgers, shown apart"
action={
<HStack gap={0.5} align="center">
<Info size={12} style={{ color: 'var(--color-ink-4)' }} />
<Text type="body" size="xsm" color="secondary">
App and counter sales are not added together
</Text>
</HStack>
}
/>
title="Revenue" />
<div className="kpi-grid">
<KpiCard
label="App revenue"
@@ -173,7 +171,7 @@ export function ConsolePage() {
/>
<KpiCard
label="Cancelled"
value={percent(cancelShare)}
value={percent(cancelShare ?? 0)}
note={`${count(totals.cancelled)} of ${count(totals.onlineOrders)} app orders`}
tone={cancelShare !== null && cancelShare > 0.1 ? 'error' : 'neutral'}
icon={<TrendingDown size={15} />}
@@ -187,21 +185,12 @@ export function ConsolePage() {
icon={<Monitor size={15} />}
/>
</div>
{/* The caveat that keeps the two figures honest, stated where the
figures are rather than in a doc nobody opens at 9am. */}
<Text type="body" size="xsm" color="secondary">
App revenue currently also contains any counter bills imported from a spreadsheet —
Fiesta records the bill number on the order but does not return it, so the two cannot yet
be told apart. Live till sales are counted separately and are never double-counted.
</Text>
</VStack>
{/* ── Branch overview ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader
title={selected === null ? 'Branches' : 'Branch'}
note="how each shop is trading"
title={isPinned ? 'Your shop' : selected === null ? 'Branches' : 'Branch'}
/>
{isLoading ? (
@@ -231,7 +220,7 @@ export function ConsolePage() {
{/* ── Comparison, only when there is something to compare ─────────── */}
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="Comparison" note="the same metrics, side by side" />
<SectionHeader title="Comparison" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<ComparisonTable rows={rows} />
@@ -244,7 +233,6 @@ export function ConsolePage() {
<VStack gap={1.5}>
<SectionHeader
title="Till sync"
note="a queue nobody can see from the shop floor"
action={
<Text type="body" size="xsm" color="secondary">
Tills report every 30s
@@ -260,7 +248,7 @@ export function ConsolePage() {
{/* ── Action required ─────────────────────────────────────────────── */}
<VStack gap={1.5}>
<SectionHeader title="Needs you" note="ranked by what costs money soonest" />
<SectionHeader title="Needs you" />
{alerts.length === 0 ? (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
@@ -466,27 +454,27 @@ function ComparisonTable({ rows }: { rows: Row[] }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Metric</Th>
<Th>Metric</Th>
{rows.map((row) => (
<Th key={row.branch.locationid} align="right">
<Th key={row.branch.locationid}>
{branchLabel(row.branch.locationname)}
</Th>
))}
<Th align="right">All</Th>
<Th>All</Th>
</tr>
</thead>
<tbody>
{metrics.map((metric) => (
<tr key={metric.label}>
<Td align="left" isMuted>
<Td isMuted>
{metric.label}
</Td>
{rows.map((row) => (
<Td key={row.branch.locationid} align="right">
<Td key={row.branch.locationid}>
{metric.get(row)}
</Td>
))}
<Td align="right" isStrong>
<Td isStrong>
{metric.total}
</Td>
</tr>
@@ -541,39 +529,39 @@ function TillTable({ rows }: { rows: Row[] }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Till</Th>
<Th align="left">Branch</Th>
<Th align="left">Status</Th>
<Th align="right">Queued</Th>
<Th align="right">Oldest</Th>
<Th align="right">Today</Th>
<Th align="left">Notes</Th>
<Th>Till</Th>
<Th>Branch</Th>
<Th>Status</Th>
<Th>Queued</Th>
<Th>Oldest</Th>
<Th>Today</Th>
<Th>Notes</Th>
</tr>
</thead>
<tbody>
{terminals.map(({ branch, terminal }) => (
<tr key={`${branch}:${terminal.terminalId}`}>
<Td align="left" isStrong>
<Td isStrong>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{terminal.terminalId}
</span>
</Td>
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(branch)}
</Td>
<Td align="left">
<Td>
<Badge variant={SYNC_TONE[terminal.state]} label={SYNC_LABEL[terminal.state]} />
</Td>
<Td align="right" isStrong={terminal.pendingBills > 0}>
<Td isStrong={terminal.pendingBills > 0}>
{terminal.pendingBills > 0 ? count(terminal.pendingBills) : '—'}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{shortAge(terminal.oldestPendingMs)}
</Td>
<Td align="right">
<Td>
{count(terminal.todayBills)} · {money(terminal.todayAmount)}
</Td>
<Td align="left" isMuted>
<Td isMuted>
{deviceNote(terminal)}
</Td>
</tr>
@@ -602,7 +590,7 @@ interface Alert {
* business cannot see, and it is the only item here that gets worse purely by
* being ignored.
*/
function buildAlerts(rows: Row[]): Alert[] {
function buildAlerts(rows: Row[], base: string, isPinned: boolean): Alert[] {
const alerts: Alert[] = [];
for (const row of rows) {
@@ -622,7 +610,7 @@ function buildAlerts(rows: Row[]): Alert[] {
icon: <Monitor size={15} />,
title: `${count(bills)} bill${bills === 1 ? '' : 's'} stranded at ${row.branch.locationname}`,
detail: `${stranded.length > 1 ? `${stranded.length} tills, oldest on ` : ''}${worst.terminalId}, waiting ${shortAge(oldest)}. Takings already collected that the business cannot see.`,
to: '/admin/terminals',
to: `${base}/terminals`,
action: 'Open Terminals',
});
}
@@ -651,22 +639,39 @@ function buildAlerts(rows: Row[]): Alert[] {
.map(({ terminal }) => `${terminal.terminalId} (${SYNC_LABEL[terminal.state].toLowerCase()})`)
.join(', ')
.concat(branches.length > 1 ? ` across ${branches.length} branches` : ` at ${branches[0]}`),
to: '/admin/terminals',
to: `${base}/terminals`,
action: 'See tills',
});
}
const waiting = rows.reduce((sum, row) => sum + row.pendingRequests, 0);
if (waiting > 0) {
alerts.push({
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
to: '/admin/inventory',
action: 'Review requests',
});
// Same fact, opposite side of the counter. The merchant is the one holding
// it up; the shop is the one waiting — and a shop told to "approve" a
// request it raised itself would go looking for a button that is not there,
// because the backend gives it to nobody but the tenant.
alerts.push(
isPinned
? {
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} waiting on head office`,
detail:
'Your store administrator approves these. Confirm the goods here once they arrive.',
to: `${base}/products`,
action: 'Open Products',
}
: {
id: 'requests',
tone: 'warning',
icon: <ClipboardList size={15} />,
title: `${count(waiting)} stock request${waiting === 1 ? '' : 's'} awaiting approval`,
detail: 'Nothing reaches a shelf until you approve it — a store cannot add its own stock.',
to: '/admin/inventory',
action: 'Review requests',
},
);
}
for (const row of rows) {
@@ -678,7 +683,7 @@ function buildAlerts(rows: Row[]): Alert[] {
icon: <PackageX size={15} />,
title: `${percent(cancelRate)} of app orders cancelled at ${row.branch.locationname}`,
detail: `${count(row.cancelled)} of ${count(row.onlineOrders)} orders. Usually stock the app thinks is on the shelf and is not.`,
to: '/admin/sales',
to: `${base}/sales`,
action: 'Open Sales',
});
}
@@ -738,11 +743,11 @@ function AlertRow({ alert }: { alert: Alert }) {
/* ── Table primitives ────────────────────────────────────────────────────── */
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -758,24 +763,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>

View File

@@ -9,12 +9,17 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import {
AlertTriangle,
Boxes,
Check,
CheckCheck,
ClipboardList,
DownloadCloud,
UploadCloud,
IndianRupee,
Info,
Layers,
PackageSearch,
Send,
PackageX,
X,
} from 'lucide-react';
import { stockApi } from '@/api/stock';
@@ -25,9 +30,10 @@ import { SectionHeader } from '@/components/SectionHeader';
import { queryKeys } from '@/queries/keys';
import { useStockRequests, useStockStatement } from '@/queries/hooks';
import { useBranchScope } from '../BranchScope';
import { ProductsPanel } from '../ProductsPanel';
import { branchLabel, count, money } from '../format';
type Tab = 'catalogue' | 'stock' | 'requests';
type Tab = 'products' | 'stock' | 'requests';
/**
* Inventory — catalogue, stock and the approval queue.
@@ -45,8 +51,12 @@ type Tab = 'catalogue' | 'stock' | 'requests';
* backend catch up. Nothing on screen claims the selection is in force.
*/
export function InventoryPage() {
const { branches, scoped, selected, current, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('requests');
const { branches, selected, current, tenantid } = useBranchScope();
const [tab, setTab] = useState<Tab>('products');
// Owned here so the button can live in the tab row while the drawer it opens
// stays inside the panel that uses it.
const [isImporting, setImporting] = useState(false);
const [isUploading, setUploading] = useState(false);
const requests = useStockRequests(
tenantid ? { tenantid, locationid: selected ?? undefined } : undefined,
@@ -63,13 +73,42 @@ export function InventoryPage() {
title="Inventory"
count={selected === null ? `${branches.length} branches` : current?.locationname}
description="What you sell, where it is stocked, and what the shops are asking you for."
/* The two product actions live here, not in the tab row, so their
position is fixed — they no longer appear and disappear as the tab
changes, and nothing below shifts sideways when they do.
The "N awaiting approval" badge that used to sit here is gone: the
Stock requests tab already carries the same count, and a number
stated twice on one screen invites the reader to check whether the
two agree. */
actions={
pending.length > 0 ? (
<Badge variant="warning" label={`${pending.length} awaiting approval`} />
) : undefined
<HStack gap={0.5} align="center" wrap="wrap">
<BarAction
label="Upload sheet"
icon={<UploadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<BarAction
label="Import products"
icon={<DownloadCloud size={14} />}
isDisabled={!tenantid}
onClick={() => setImporting(true)}
/>
</HStack>
}
tabs={
/* The tab row carries the Products action on its right, rather than
giving it a row of its own below. It only appears on that tab —
an action for a panel you are not looking at is a misfire waiting
to happen. */
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<TabButton
label="Products"
icon={<PackageSearch size={14} />}
isActive={tab === 'products'}
onClick={() => setTab('products')}
/>
<TabButton
label="Stock requests"
icon={<ClipboardList size={14} />}
@@ -83,22 +122,21 @@ export function InventoryPage() {
isActive={tab === 'stock'}
onClick={() => setTab('stock')}
/>
<TabButton
label="Catalogue"
icon={<PackageSearch size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
</HStack>
}
/>
{tab === 'requests' ? (
{tab === 'products' ? (
<ProductsPanel
isImporting={isImporting}
onCloseImport={() => setImporting(false)}
isUploading={isUploading}
onCloseUpload={() => setUploading(false)}
/>
) : tab === 'requests' ? (
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
) : tab === 'stock' ? (
<StockPanel />
) : (
<CataloguePanel branchCount={scoped.length} />
<StockPanel />
)}
</VStack>
);
@@ -154,17 +192,19 @@ function RequestsPanel({
value={count(pending.reduce((sum, entry) => sum + entry.qty, 0))}
note="across open requests"
tone="neutral"
icon={<Boxes size={15} />}
/>
<KpiCard
label="Settled"
value={count(settled.length)}
note="approved or rejected"
tone="neutral"
icon={<CheckCheck size={15} />}
/>
</div>
<VStack gap={1.5}>
<SectionHeader title="Open requests" note="a store cannot add its own stock" />
<SectionHeader title="Open requests" />
{isLoading ? (
<Card padding={0} elevation="low">
<VStack padding={3}>
@@ -268,29 +308,29 @@ function RequestsPanel({
{settled.length > 0 ? (
<VStack gap={1.5}>
<SectionHeader title="Decided" note="most recent first" />
<SectionHeader title="Decided" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Product</Th>
<Th align="left">Branch</Th>
<Th align="right">Qty</Th>
<Th align="left">Outcome</Th>
<Th>Product</Th>
<Th>Branch</Th>
<Th>Qty</Th>
<Th>Outcome</Th>
</tr>
</thead>
<tbody>
{settled.slice(0, 25).map((request) => (
<tr key={request.requestid}>
<Td align="left" isStrong>
<Td isStrong>
{request.productname ?? `Product ${request.productid}`}
</Td>
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(request.locationname) || `Branch ${request.locationid}`}
</Td>
<Td align="right">{count(request.qty)}</Td>
<Td align="left">
<Td>{count(request.qty)}</Td>
<Td>
<Badge
variant={request.status === 'Received' ? 'success' : 'neutral'}
label={request.status === 'Received' ? 'Stock added' : request.status}
@@ -346,6 +386,7 @@ function StockPanel() {
value={money(value)}
note={`${count(rows.length)} products`}
tone="accent"
icon={<IndianRupee size={15} />}
/>
<KpiCard
label="Low stock"
@@ -359,13 +400,13 @@ function StockPanel() {
value={count(out)}
note="nothing on the shelf"
tone={out > 0 ? 'error' : 'success'}
icon={<PackageX size={15} />}
/>
</div>
<VStack gap={1.5}>
<SectionHeader
title={branch ? `Stock at ${branch.locationname}` : 'Stock'}
note="opening → in → out → closing"
action={
<div style={{ width: 240 }}>
<TextInput
@@ -381,16 +422,6 @@ function StockPanel() {
}
/>
{scoped.length > 1 ? (
<HStack gap={1} align="start">
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<Text type="body" size="xsm" color="secondary">
Showing <strong>{branch?.locationname}</strong>. The stock statement is scoped to one
branch by the backend — pick a branch in the header to switch.
</Text>
</HStack>
) : null}
<Card padding={0} elevation="low">
{statement.isLoading ? (
<VStack padding={3}>
@@ -413,13 +444,13 @@ function StockPanel() {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Product</Th>
<Th align="right">Opening</Th>
<Th align="right">In</Th>
<Th align="right">Out</Th>
<Th align="right">Closing</Th>
<Th align="right">Value</Th>
<Th align="left">Status</Th>
<Th>Product</Th>
<Th>Opening</Th>
<Th>In</Th>
<Th>Out</Th>
<Th>Closing</Th>
<Th>Value</Th>
<Th>Status</Th>
</tr>
</thead>
<tbody>
@@ -427,21 +458,21 @@ function StockPanel() {
const closing = row.closing ?? 0;
return (
<tr key={row.productid}>
<Td align="left" isStrong>
<Td isStrong>
{row.productname ?? `Product ${row.productid}`}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{count(row.opening)}
</Td>
<Td align="right">{count(row.credit)}</Td>
<Td align="right">{count(row.debit)}</Td>
<Td align="right" isStrong>
<Td>{count(row.credit)}</Td>
<Td>{count(row.debit)}</Td>
<Td isStrong>
{count(closing)}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{money(closing * (row.retailprice ?? 0))}
</Td>
<Td align="left">
<Td>
<Badge
variant={closing <= 0 ? 'error' : closing <= 5 ? 'warning' : 'success'}
label={closing <= 0 ? 'Out' : closing <= 5 ? 'Low' : 'In stock'}
@@ -462,166 +493,58 @@ function StockPanel() {
/* ── Catalogue ───────────────────────────────────────────────────────────── */
/**
* The catalogue tiers and how a product moves between them.
*
* Deliberately an explanation plus the two entry points rather than a second
* copy of the Nearle Admin's catalogue browser: the import UI already exists
* and works, and duplicating it here is how two screens drift apart.
*/
function CataloguePanel({ branchCount }: { branchCount: number }) {
return (
<VStack gap={3}>
<VStack gap={1.5}>
<SectionHeader title="How a product reaches a shelf" note="four steps, two entry points" />
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<div className="form-grid">
<Step
n={1}
title="Into your catalogue"
body="Import from the global catalogue, or upload your own spreadsheet. Only you can do either — a store manager cannot."
/>
<Step
n={2}
title="Price it"
body="The global catalogue carries a price range, not a price. Yours is the one customers pay, and it is set per branch."
/>
<Step
n={3}
title="Publish"
body={`Publishing releases the product to all ${branchCount} branch${branchCount === 1 ? '' : 'es'}. Until then it is yours alone and no store can see it.`}
/>
<Step
n={4}
title="Stock it"
body="A published product is not stock. A store raises a request, you approve it, and only then does anything reach the shelf."
/>
</div>
</VStack>
</Card>
</VStack>
<VStack gap={1.5}>
<SectionHeader title="Add products" note="both paths end in the same catalogue" />
<div className="card-grid">
<EntryCard
icon={<PackageSearch size={18} />}
title="From the global catalogue"
body="1,267 FMCG products with images and pack sizes. Pick what you sell and set your own price."
action="Browse catalogue"
/>
<EntryCard
icon={<Send size={18} />}
title="From a spreadsheet"
body="Your own product list. Validated before anything is created — you see valid, duplicate and error rows, and can download the failures to fix and re-upload."
action="Upload a sheet"
/>
</div>
</VStack>
<Card padding={0} elevation="low">
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Two things the spec asks for that the backend cannot do yet
</Text>
<Text type="body" size="xsm" color="secondary">
<strong>Per-branch assignment.</strong> Publishing releases a product to every
branch — `PublishProduct` takes a tenant and a product, with no location list. The
per-branch controls are built to your spec and will start working the day that
parameter lands; nothing here pretends the selection is in force today.
</Text>
<Text type="body" size="xsm" color="secondary">
<strong>Duplicate detection by SKU.</strong> Across the live catalogue 6,245 products
share 93 distinct SKU values and 154 are blank, so a SKU cannot decide that two rows
are the same product. Upload validation warns on repeats but will not merge on them,
and there is no barcode column anywhere in Fiesta to fall back to.
</Text>
</VStack>
</HStack>
</Card>
</VStack>
);
}
function Step({ n, title, body }: { n: number; title: string; body: string }) {
return (
<VStack gap={0.5}>
<HStack gap={1} align="center">
<span
style={{
width: 22,
height: 22,
borderRadius: 999,
flex: 'none',
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
fontSize: 11,
fontWeight: 700,
}}
>
{n}
</span>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
</HStack>
<Text type="body" size="xsm" color="secondary">
{body}
</Text>
</VStack>
);
}
function EntryCard({
icon,
title,
body,
action,
}: {
icon: React.ReactNode;
title: string;
body: string;
action: string;
}) {
return (
<Card padding={0} elevation="low">
<VStack gap={1.5} padding={2}>
<span
style={{
width: 36,
height: 36,
borderRadius: 10,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
display: 'grid',
placeItems: 'center',
}}
>
{icon}
</span>
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
<Text type="body" size="xsm" color="secondary">
{body}
</Text>
</VStack>
<HStack>
<Button label={action} variant="secondary" size="sm" />
</HStack>
</VStack>
</Card>
);
}
/* ── Primitives ──────────────────────────────────────────────────────────── */
/**
* An action on the tab bar, drawn as the tabs are drawn.
*
* Same height, radius, type size and ink as `TabButton`, with no border and no
* fill — a filled pill on the right of a row of flat tabs reads as a different
* control system sitting in the same strip. It takes a hover tint instead of a
* resting one, which is what separates an action from a tab that is simply not
* selected.
*/
function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'default' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}
function TabButton({
label,
icon,
@@ -680,11 +603,11 @@ function TabButton({
);
}
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -700,24 +623,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>

View File

@@ -188,7 +188,6 @@ export function OnboardBranchPage() {
<VStack gap={2} padding={3}>
<SectionHeader
title="Owner and identity"
note="which tenant this branch belongs to"
action={<Store size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid">
@@ -228,7 +227,6 @@ export function OnboardBranchPage() {
<VStack gap={2} padding={3}>
<SectionHeader
title="Location"
note="where the outlet sits"
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<TextInput
@@ -250,7 +248,6 @@ export function OnboardBranchPage() {
<VStack gap={2} padding={3}>
<SectionHeader
title="Hours and delivery"
note="what the customer app uses to decide if an address is servable"
action={<Clock size={17} style={{ color: 'var(--color-slate-400)' }} />}
/>
<div className="form-grid-4">

View File

@@ -3,7 +3,22 @@ import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { BarChart3, Boxes, IndianRupee, Info } from 'lucide-react';
import {
ArrowDownUp,
BadgePercent,
BarChart3,
Boxes,
Calculator,
CircleCheck,
IndianRupee,
Package,
Percent,
Receipt,
ReceiptText,
ShoppingCart,
TrendingDown,
Warehouse,
} from 'lucide-react';
import type { DateRange } from '@/api/insights';
import { KpiCard } from '@/components/KpiCard';
import { PageHeader } from '@/components/PageHeader';
@@ -126,24 +141,28 @@ export function ReportsPage() {
value={money(totals.appRevenue)}
note={`${count(totals.appOrders)} orders`}
tone="accent"
icon={<ShoppingCart size={15} />}
/>
<KpiCard
label="Counter revenue"
value={money(totals.counterRevenue)}
note={`${count(totals.counterBills)} bills`}
tone="success"
icon={<Receipt size={15} />}
/>
<KpiCard
label="Tax collected"
value={money(totals.tax)}
note="counter sales"
tone="neutral"
icon={<Percent size={15} />}
/>
<KpiCard
label="Discount given"
value={money(totals.discount)}
note="counter sales"
tone="neutral"
icon={<BadgePercent size={15} />}
/>
</div>
@@ -151,35 +170,35 @@ export function ReportsPage() {
{rows.length > 1 ? (
<VStack gap={1.5}>
<SectionHeader title="By branch" note="within a channel, so the comparison holds" />
<SectionHeader title="By branch" />
<Card padding={0} elevation="low">
<div className="table-scroll">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Branch</Th>
<Th align="right">App revenue</Th>
<Th align="right">Counter revenue</Th>
<Th align="right">App orders</Th>
<Th align="right">Counter bills</Th>
<Th align="right">Avg bill</Th>
<Th>Branch</Th>
<Th>App revenue</Th>
<Th>Counter revenue</Th>
<Th>App orders</Th>
<Th>Counter bills</Th>
<Th>Avg bill</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.branch.locationid}>
<Td align="left" isStrong>
<Td isStrong>
{branchLabel(row.branch.locationname)}
</Td>
<Td align="right">{money(row.appRevenue)}</Td>
<Td align="right">{money(row.counterRevenue)}</Td>
<Td align="right" isMuted>
<Td>{money(row.appRevenue)}</Td>
<Td>{money(row.counterRevenue)}</Td>
<Td isMuted>
{count(row.appOrders)}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{count(row.counterBills)}
</Td>
<Td align="right" isMuted>
<Td isMuted>
{row.counterBills > 0
? money(row.counterRevenue / row.counterBills)
: '—'}
@@ -201,6 +220,7 @@ export function ReportsPage() {
value={count(totals.delivered)}
note={`of ${count(totals.appOrders)} app orders`}
tone="success"
icon={<CircleCheck size={15} />}
fill={share(totals.delivered, totals.appOrders) ?? undefined}
/>
<KpiCard
@@ -210,6 +230,7 @@ export function ReportsPage() {
tone={
(share(totals.cancelled, totals.appOrders) ?? 0) > 0.1 ? 'error' : 'neutral'
}
icon={<TrendingDown size={15} />}
fill={share(totals.cancelled, totals.appOrders) ?? undefined}
/>
<KpiCard
@@ -217,37 +238,20 @@ export function ReportsPage() {
value={count(totals.counterBills)}
note="rung up at a till"
tone="neutral"
icon={<ReceiptText size={15} />}
/>
<KpiCard
label="Average bill"
value={
totals.counterBills > 0
? money(totals.counterRevenue / totals.counterBills)
: '—'
}
value={money(
totals.counterBills > 0 ? totals.counterRevenue / totals.counterBills : 0,
)}
note="counter sales"
tone="neutral"
icon={<Calculator size={15} />}
/>
</div>
<DailyTrend rows={rows} />
<Card padding={0} elevation="low">
<HStack gap={1.5} align="start" style={{ padding: 16 }}>
<Info size={16} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<VStack gap={0.5}>
<Text type="label" size="sm" weight="semibold">
Fast and slow movers need one more read
</Text>
<Text type="body" size="xsm" color="secondary">
Product-level performance comes from the stock statement, not from the sales
summary — movement out of stock is what makes a product fast or slow. It is on the
Inventory tab, per branch, because the statement endpoint is scoped to a single
location.
</Text>
</VStack>
</HStack>
</Card>
</VStack>
) : (
<InventoryReport />
@@ -269,7 +273,7 @@ function ChannelSplit({ app, counter }: { app: number; counter: number }) {
const max = Math.max(app, counter, 1);
return (
<VStack gap={1.5}>
<SectionHeader title="App versus counter" note="compared, not combined" />
<SectionHeader title="App versus counter" />
<Card padding={0} elevation="low">
<VStack gap={2} padding={3}>
<Bar label="App" value={app} max={max} color="var(--color-brand)" />
@@ -353,7 +357,7 @@ function DailyTrend({ rows }: { rows: { byDay: { day?: string; amount?: number }
return (
<VStack gap={1.5}>
<SectionHeader title="Counter takings by day" note="every till, combined" />
<SectionHeader title="Counter takings by day" />
<Card padding={0} elevation="low">
<div style={{ padding: '24px 20px', height: 260, overflowX: 'auto' }}>
<div style={{ minWidth: 400, height: '100%' }}>
@@ -421,19 +425,27 @@ function InventoryReport() {
value={money(value)}
note={branch?.locationname ?? '—'}
tone="accent"
icon={<Warehouse size={15} />}
/>
<KpiCard
label="Products"
value={count(rows.length)}
note="with a stock record"
tone="neutral"
icon={<Package size={15} />}
/>
<KpiCard label="Products" value={count(rows.length)} note="with a stock record" tone="neutral" />
<KpiCard
label="Units moved"
value={count(rows.reduce((sum, row) => sum + (row.debit ?? 0), 0))}
note="off the shelf this period"
tone="neutral"
icon={<ArrowDownUp size={15} />}
/>
</div>
<div className="form-grid">
<MoverList title="Fast moving" note="most units off the shelf" rows={fast} />
<MoverList title="Slow moving" note="in stock, barely selling" rows={slow} />
<MoverList title="Fast moving" rows={fast} />
<MoverList title="Slow moving" rows={slow} />
</div>
</VStack>
);
@@ -441,16 +453,14 @@ function InventoryReport() {
function MoverList({
title,
note,
rows,
}: {
title: string;
note: string;
rows: { productid: number; productname?: string; debit?: number; closing?: number }[];
}) {
return (
<VStack gap={1.5}>
<SectionHeader title={title} note={note} />
<SectionHeader title={title} />
<Card padding={0} elevation="low">
{rows.length === 0 ? (
<VStack padding={3}>
@@ -529,11 +539,11 @@ function TabButton({
);
}
function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 14px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -549,24 +559,22 @@ function Th({ children, align }: { children: React.ReactNode; align: 'left' | 'r
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '11px 14px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
fontVariantNumeric: align === 'right' ? 'tabular-nums' : undefined,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>

View File

@@ -4,7 +4,7 @@ import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Bike, MapPin, Package, Receipt, ShoppingCart, Truck } from 'lucide-react';
import { Banknote, Bike, Calculator, IndianRupee, MapPin, Package, Percent, Receipt, ReceiptText, ShoppingCart, Truck, Upload, UserX } from 'lucide-react';
import type { DateRange } from '@/api/insights';
import type { DeliveryRow, OrderRow, PosSale } from '@/api/types';
import { KpiCard } from '@/components/KpiCard';
@@ -30,6 +30,7 @@ import {
syncLagMs,
type StatusKey,
} from '../orderStatus';
import { CounterSalesDrawer } from '../offlineSales/CounterSalesDrawer';
import { OrderDetailDrawer, type RowKind } from '../OrderDetailDrawer';
import { shortAge } from '../posStatus';
@@ -52,7 +53,8 @@ type Tab = 'orders' | 'deliveries' | 'counter';
* every row would be a column of dashes.
*/
export function SalesPage() {
const { scoped, selected, current, branches, tenantid } = useBranchScope();
const { scoped, selected, current, branches, tenantid, isPinned } = useBranchScope();
const [isUploading, setUploading] = useState(false);
const [tab, setTab] = useState<Tab>('orders');
const [status, setStatus] = useState<StatusKey>('all');
const [preset, setPreset] = useState<RangePreset>('month');
@@ -161,14 +163,25 @@ export function SalesPage() {
description="Every order placed in the app, the delivery jobs riding on them, and every bill rung up at a till."
isLive
actions={
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
<HStack gap={0.5} align="center" wrap="wrap">
{/* Counter sales that never touched a till. Sits beside the date
range rather than under the Counter tab because it is how the
figures on every tab get there for a shop with no POS. */}
<BarAction
label="Upload counter sales"
icon={<Upload size={14} />}
isDisabled={!tenantid}
onClick={() => setUploading(true)}
/>
<DateRangePicker
preset={preset}
range={range}
onChange={(nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
}}
/>
</HStack>
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
@@ -239,9 +252,10 @@ export function SalesPage() {
/>
<KpiCard
label="Cash to collect"
value={orderTotals.cod > 0 ? money(orderTotals.cod) : '—'}
value={money(orderTotals.cod)}
note="COD on these orders"
tone={orderTotals.cod > 0 ? 'warning' : 'neutral'}
icon={<Banknote size={15} />}
/>
<KpiCard
label="Items"
@@ -252,16 +266,16 @@ export function SalesPage() {
/>
<KpiCard
label="Average order"
value={orderRows.length > 0 ? money(orderTotals.value / orderRows.length) : '—'}
value={money(orderRows.length > 0 ? orderTotals.value / orderRows.length : 0)}
note="in this view"
tone="neutral"
icon={<Calculator size={15} />}
/>
</div>
<VStack gap={1.5}>
<SectionHeader
title="Orders"
note="newest first"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, customer…" />}
/>
<OrdersTable
@@ -301,12 +315,14 @@ export function SalesPage() {
value={count(deliveryTotals.unassigned)}
note="no rider yet"
tone={deliveryTotals.unassigned > 0 ? 'warning' : 'success'}
icon={<UserX size={15} />}
/>
<KpiCard
label="Job value"
value={money(deliveryTotals.value)}
note="what these deliveries carry"
tone="neutral"
icon={<IndianRupee size={15} />}
/>
<KpiCard
label="Rider charges"
@@ -320,7 +336,6 @@ export function SalesPage() {
<VStack gap={1.5}>
<SectionHeader
title="Deliveries"
note="planned against actual"
action={<Search value={keyword} onChange={setKeyword} placeholder="Order ID, rider…" />}
/>
<DeliveriesTable
@@ -355,17 +370,17 @@ export function SalesPage() {
/>
<KpiCard
label="Average bill"
value={
counterTotals.bills > 0 ? money(counterTotals.gross / counterTotals.bills) : '—'
}
value={money(counterTotals.bills > 0 ? counterTotals.gross / counterTotals.bills : 0)}
note="per transaction"
tone="neutral"
icon={<Calculator size={15} />}
/>
<KpiCard
label="Tax collected"
value={money(counterTotals.tax)}
note="on counter sales"
tone="neutral"
icon={<Percent size={15} />}
/>
<KpiCard
label="Bills listed"
@@ -376,13 +391,13 @@ export function SalesPage() {
: 'all of them'
}
tone="neutral"
icon={<ReceiptText size={15} />}
/>
</div>
<VStack gap={1.5}>
<SectionHeader
title="Counter bills"
note="as the tills reported them"
action={
<Search value={keyword} onChange={setKeyword} placeholder="Invoice, till, cashier…" />
}
@@ -396,6 +411,15 @@ export function SalesPage() {
</>
) : null}
{isUploading && tenantid ? (
<CounterSalesDrawer
tenantid={tenantid}
{...(isPinned && current ? { locationid: current.locationid } : {})}
branchIds={branches.map((branch) => branch.locationid)}
onClose={() => setUploading(false)}
/>
) : null}
{detail ? (
<OrderDetailDrawer row={detail.row} kind={detail.kind} onClose={() => setDetail(null)} />
) : null}
@@ -433,16 +457,16 @@ function OrdersTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="right">#</Th>
<Th align="left">Order</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Pickup</Th>
<Th align="left">Drop</Th>
<Th align="right">Qty</Th>
<Th align="right">COD</Th>
<Th align="right">Amount</Th>
<Th align="left">Status</Th>
<Th align="right" />
<Th>#</Th>
<Th>Order</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Pickup</Th>
<Th>Drop</Th>
<Th>Qty</Th>
<Th>COD</Th>
<Th>Amount</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
@@ -453,17 +477,17 @@ function OrdersTable({
const amount = orderValue(row);
return (
<Row key={row.orderheaderid} onClick={() => onOpen(row)}>
<Td align="right" isMuted>
<Td isMuted>
<Mono>{index + 1}</Mono>
</Td>
<Td align="left">
<Td>
<Primary>
<Mono>{row.orderid || `#${row.orderheaderid}`}</Mono>
</Primary>
<Secondary>{stamp(row.orderdate)}</Secondary>
</Td>
{showBranch ? (
<Td align="left">
<Td>
<span
style={{
display: 'inline-flex',
@@ -482,29 +506,29 @@ function OrdersTable({
) : null}
</Td>
) : null}
<Td align="left">
<Td>
<Primary>{row.pickupcustomer || branchLabel(row.locationname)}</Primary>
<Secondary>{row.pickupsuburb || row.pickupaddress}</Secondary>
</Td>
<Td align="left">
<Td>
<Primary>{row.deliverycustomer || '—'}</Primary>
<Secondary>{row.deliverysuburb || row.deliveryaddress}</Secondary>
</Td>
<Td align="right" isMuted>
<Td isMuted>
<Mono>{orderQuantity(row) || '—'}</Mono>
</Td>
{/* Only rendered when there is cash to collect — a COD column
of dashes is a column that costs width and says nothing. */}
<Td align="right" isStrong={cod > 0} isMuted={cod === 0}>
<Td isStrong={cod > 0} isMuted={cod === 0}>
<Mono>{cod > 0 ? moneyExact(cod) : '—'}</Mono>
</Td>
<Td align="right" isStrong={amount > 0} isMuted={amount === 0}>
<Td isStrong={amount > 0} isMuted={amount === 0}>
<Mono>{amount > 0 ? moneyExact(amount) : '—'}</Mono>
</Td>
<Td align="left">
<Td>
<Chip label={row.orderstatus} color={statusColor(ORDER_STATUS, row.orderstatus)} />
</Td>
<Td align="right">
<Td>
<ViewHint />
</Td>
</Row>
@@ -547,15 +571,15 @@ function DeliveriesTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Order</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Pickup</Th>
<Th align="left">Drop</Th>
<Th align="left">Rider</Th>
<Th align="right">KM</Th>
<Th align="right">Charge / value</Th>
<Th align="left">Status</Th>
<Th align="left">Notes</Th>
<Th>Order</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Pickup</Th>
<Th>Drop</Th>
<Th>Rider</Th>
<Th>KM</Th>
<Th>Charge / value</Th>
<Th>Status</Th>
<Th>Notes</Th>
</tr>
</thead>
<tbody>
@@ -579,26 +603,26 @@ function DeliveriesTable({
return (
<Row key={row.deliveryid} onClick={() => onOpen(row)}>
<Td align="left">
<Td>
<Primary>
<Mono>{row.orderid || `DLV-${row.deliveryid}`}</Mono>
</Primary>
<Secondary>{stamp(row.assigntime || row.deliverydate)}</Secondary>
</Td>
{showBranch ? (
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(row.locationname)}
</Td>
) : null}
<Td align="left">
<Td>
<Primary w={124}>{pickupName || pickupAddr || '—'}</Primary>
<Secondary w={124}>{pickupName ? pickupAddr : ''}</Secondary>
</Td>
<Td align="left">
<Td>
<Primary w={124}>{dropName || dropAddr || '—'}</Primary>
<Secondary w={124}>{dropName ? dropAddr : ''}</Secondary>
</Td>
<Td align="left">
<Td>
{row.ridername ? (
<span
style={{
@@ -642,24 +666,24 @@ function DeliveriesTable({
{/* Planned above, actual below — the pair is the point. A
delivery that was quoted 3km and rode 9 is the row worth
finding, and one number cannot show it. */}
<Td align="right">
<Td>
<Pill color="#ef4444">{planned ? planned.toFixed(1) : '—'}</Pill>
{actual > 0 ? <Pill color="#10b981">{actual.toFixed(1)}</Pill> : null}
</Td>
<Td align="right">
<Td>
{charge > 0 ? <Pill color="#ef4444">{moneyExact(charge)}</Pill> : null}
{value > 0 ? <Pill color="#10b981">{moneyExact(value)}</Pill> : null}
{charge === 0 && value === 0 ? (
<span style={{ color: 'var(--color-ink-4)' }}>—</span>
) : null}
</Td>
<Td align="left">
<Td>
<Chip
label={row.orderstatus}
color={statusColor(DELIVERY_STATUS, row.orderstatus)}
/>
</Td>
<Td align="left" isMuted>
<Td isMuted>
<span
title={notes}
style={{
@@ -711,16 +735,16 @@ function BillsTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Invoice</Th>
<Th align="left">Time</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Till</Th>
<Th align="left">Cashier</Th>
<Th align="left">Customer</Th>
<Th align="left">Paid by</Th>
<Th align="right">Items</Th>
<Th align="right">Tax</Th>
<Th align="right">Total</Th>
<Th>Invoice</Th>
<Th>Time</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Till</Th>
<Th>Cashier</Th>
<Th>Customer</Th>
<Th>Paid by</Th>
<Th>Items</Th>
<Th>Tax</Th>
<Th>Total</Th>
</tr>
</thead>
<tbody>
@@ -731,7 +755,7 @@ function BillsTable({
const lag = syncLagMs(bill);
return (
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
<Td align="left">
<Td>
<Primary>
<Mono>{bill.invoicenumber || `#${bill.posorderid}`}</Mono>
</Primary>
@@ -748,36 +772,36 @@ function BillsTable({
) : null}
</Secondary>
</Td>
<Td align="left" isMuted>
<Td isMuted>
{clockOf(bill.billedat)}
</Td>
{showBranch ? (
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(branch)}
</Td>
) : null}
<Td align="left" isMuted>
<Td isMuted>
<Mono>{bill.terminalid || '—'}</Mono>
</Td>
<Td align="left" isMuted>
<Td isMuted>
{bill.cashiername || '—'}
</Td>
<Td align="left" isMuted>
<Td isMuted>
{bill.customername || bill.customermobile || 'Walk-in'}
</Td>
<Td align="left">
<Td>
<Chip
label={bill.paymentmode || 'n/a'}
color={bill.paymentmode ? 'var(--color-brand)' : 'var(--color-ink-4)'}
/>
</Td>
<Td align="right" isMuted>
<Td isMuted>
<Mono>{count(bill.itemcount)}</Mono>
</Td>
<Td align="right" isMuted>
<Td isMuted>
<Mono>{moneyExact(bill.taxamount)}</Mono>
</Td>
<Td align="right" isStrong>
<Td isStrong>
<Mono>{moneyExact(bill.total)}</Mono>
</Td>
</tr>
@@ -864,7 +888,8 @@ function Pill({ color, children }: { color: string; children: React.ReactNode })
<span
style={{
display: 'block',
marginLeft: 'auto',
// Centred with the rest of the table, not pushed to the cell's edge.
margin: '0 auto',
width: 'fit-content',
minWidth: 62,
textAlign: 'center',
@@ -1130,11 +1155,11 @@ function Row({ children, onClick }: { children: React.ReactNode; onClick: () =>
);
}
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children?: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -1150,19 +1175,17 @@ function Th({ children, align }: { children?: React.ReactNode; align: 'left' | '
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
@@ -1174,3 +1197,49 @@ function Td({
</td>
);
}
/**
* A header action drawn like a tab, so the two rows read as one bar. Same shape
* as Inventory's — copied rather than shared because the two pages own their
* chrome, and a shared one would have to take a theme prop to stay identical.
*/
function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}

View File

@@ -6,7 +6,7 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Info, Monitor, Plus, UserCog, Users } from 'lucide-react';
import { Monitor, Plus, UserCog, Users } from 'lucide-react';
import type { PosUser, StaffInfo } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { SectionHeader } from '@/components/SectionHeader';
@@ -153,11 +153,6 @@ export function UsersPage() {
<VStack gap={1.5}>
<SectionHeader
title={group === 'store' ? 'Back office' : 'Counter staff'}
note={
group === 'store'
? 'signs in to this console'
: 'signs in at a till — no console login'
}
action={
<HStack gap={1} wrap="wrap">
<div style={{ width: 170 }}>
@@ -204,29 +199,6 @@ export function UsersPage() {
)}
</VStack>
{/* Stated once, under whichever list is showing. */}
<HStack gap={1} align="start">
<Info size={13} style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }} />
<Text type="body" size="xsm" color="secondary">
{group === 'store' ? (
<>
There is no delete here. Removing an account is a hard delete with no cascade in
Fiesta, so anything that account touched would be left pointing at nothing —
deactivating keeps the history and stops the login. Passwords are not managed from
this console either, because they are stored in clear; adding a person creates the
account, and issuing its password is a backend job until that changes.
</>
) : (
<>
A till account has no console login — the backend leaves roles 7 and 8 out of every
application lookup, so a cashier is simply not found rather than refused. The PIN is
shown once when the account is created and never read back; if it is lost, reissue
rather than look it up.
</>
)}
</Text>
</HStack>
{editingPerson ? (
<PersonDrawer
row={editingPerson === 'new' ? null : editingPerson}
@@ -282,13 +254,13 @@ function StaffTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Name</Th>
<Th align="left">Role</Th>
<Th align="left">Email</Th>
<Th align="left">Mobile</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Status</Th>
<Th align="right" />
<Th>Name</Th>
<Th>Role</Th>
<Th>Email</Th>
<Th>Mobile</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
@@ -296,29 +268,29 @@ function StaffTable({
const branch = branches.find((entry) => entry.locationid === person.locationid);
return (
<tr key={person.userid}>
<Td align="left" isStrong>
<Td isStrong>
{person.fullname || `${person.firstname ?? ''} ${person.lastname ?? ''}`.trim() || '—'}
</Td>
<Td align="left">
<Td>
{/* Server-resolved. Never mapped from roleid here — most
accounts carry an id that is absent from `app_roles`. */}
<Chip label={person.rolename} tone="brand" />
</Td>
<Td align="left" isMuted>
<Td isMuted>
{person.email || '—'}
</Td>
<Td align="left" isMuted>
<Td isMuted>
{person.contactno || '—'}
</Td>
{showBranch ? (
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(branch?.locationname ?? person.locationname)}
</Td>
) : null}
<Td align="left">
<Td>
<StatusChip status={person.status} />
</Td>
<Td align="right">
<Td>
<Button label="Edit" variant="secondary" size="sm" onClick={() => onEdit(person)} />
</Td>
</tr>
@@ -359,38 +331,38 @@ function TillTable({
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<Th align="left">Name</Th>
<Th align="left">Role</Th>
<Th align="left">Mobile</Th>
{showBranch ? <Th align="left">Branch</Th> : null}
<Th align="left">Shift</Th>
<Th align="left">Status</Th>
<Th align="right" />
<Th>Name</Th>
<Th>Role</Th>
<Th>Mobile</Th>
{showBranch ? <Th>Branch</Th> : null}
<Th>Shift</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
{rows.map(({ person, branch, locationid }) => (
<tr key={person.user_id}>
<Td align="left" isStrong>
<Td isStrong>
{person.full_name || '—'}
</Td>
<Td align="left">
<Td>
<Chip
label={person.role}
tone={person.role?.toLowerCase() === 'supervisor' ? 'violet' : 'slate'}
/>
</Td>
<Td align="left" isMuted>
<Td isMuted>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{person.contactno || '—'}
</span>
</Td>
{showBranch ? (
<Td align="left" isMuted>
<Td isMuted>
{branchLabel(branch)}
</Td>
) : null}
<Td align="left" isMuted>
<Td isMuted>
{person.shift_name ? (
<>
<span style={{ display: 'block' }}>{person.shift_name}</span>
@@ -402,10 +374,10 @@ function TillTable({
'Any'
)}
</Td>
<Td align="left">
<Td>
<StatusChip status={person.status} />
</Td>
<Td align="right">
<Td>
<Button
label="Edit"
variant="secondary"
@@ -562,11 +534,11 @@ function Empty({ icon, title, body }: { icon: React.ReactNode; title: string; bo
);
}
function Th({ children, align }: { children?: React.ReactNode; align: 'left' | 'right' }) {
function Th({ children }: { children?: React.ReactNode; }) {
return (
<th
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 12px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
@@ -582,19 +554,17 @@ function Th({ children, align }: { children?: React.ReactNode; align: 'left' | '
function Td({
children,
align,
isMuted,
isStrong,
}: {
children: React.ReactNode;
align: 'left' | 'right';
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: align,
textAlign: 'center',
padding: '10px 12px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',

View File

@@ -0,0 +1,103 @@
import type { Product } from '@/api/types';
/**
* Where a product actually sits in the flow.
*
* Derived here rather than read off a column, because no single column says
* it. The backend has three signals and each one lies on its own:
*
* - `productlocations.status` is rewritten to available/outofstock by
* `syncProductLocationStatus` on EVERY stock movement, so an import's
* 'Draft' survived on one row out of 6,755. It is a stock flag wearing a
* lifecycle flag's name.
* - `publishedat` is the real release marker, but nothing in the backend
* filters on it — the till gates on price and status instead. So it says
* what the admin intended, not what the shops can see.
* - `price` is what the till actually obeys: `p.Price > 0 && status !=
* 'outofstock'` (`posRepository.go:735`).
*
* Read together they answer the question a merchant is really asking, which is
* "can this be sold, and if not, what is stopping it".
*/
export type ProductState = 'unpriced' | 'live' | 'unstocked';
export const STATE_LABEL: Record<ProductState, string> = {
// Not "Needs a price": this bucket also holds products that HAVE a price and
// simply have not been released, and a tab that mislabels a third of its own
// rows teaches the reader to distrust the other two. Each row says which of
// the two is missing — see `blockedReason`.
unpriced: 'Not ready',
live: 'On sale',
unstocked: 'No stock',
};
export const STATE_NOTE: Record<ProductState, string> = {
unpriced: 'missing a price, or not yet released — no shop can sell these',
live: 'priced, released and in stock',
unstocked: 'priced and released, but nothing on the shelf',
};
export const STATE_COLOR: Record<ProductState, string> = {
unpriced: 'var(--color-warning, #b7860b)',
live: 'var(--color-success, #10b981)',
unstocked: 'var(--color-error, #d64545)',
};
/** The effective selling price: location price, else the tenant master. */
export function effectivePrice(product: Product): number {
return product.price || product.retailprice || 0;
}
/**
* Live stock at the scoped location.
*
* `getlocationproducts` returns the ledger balance in BOTH `productstock` and
* `quantity`, aliased over the dead static columns of the same names. Reading
* either is correct; reading them in this order is defensive against an
* endpoint that only aliases one.
*/
export function stockOf(product: Product): number {
return product.productstock ?? product.quantity ?? 0;
}
export function isPublished(product: Product): boolean {
return Boolean(product.publishedat);
}
export function stateOf(product: Product): ProductState {
if (effectivePrice(product) <= 0 || !isPublished(product)) return 'unpriced';
return stockOf(product) > 0 ? 'live' : 'unstocked';
}
/**
* Why this product cannot be sold, in one sentence — or null when it can.
*
* Ordered by what blocks first. A product with no price AND no stock is a
* pricing problem: stocking it changes nothing until it has a price.
*/
export function blockedReason(product: Product): string | null {
if (effectivePrice(product) <= 0) {
return 'No price set, so a till would have to ring it up at zero — it is withheld instead.';
}
if (!isPublished(product)) {
return 'Priced, but not yet released to your shops.';
}
if (stockOf(product) <= 0) {
return 'Released and priced, but the shelf balance is zero.';
}
return null;
}
/** Parsed `productimages`, which arrives as a JSON-encoded string. */
export function imagesOf(product: Product): string[] {
const first = product.productimage ? [product.productimage] : [];
if (!product.productimages) return first;
try {
const parsed: unknown = JSON.parse(product.productimages);
if (!Array.isArray(parsed)) return first;
const urls = parsed.filter((entry): entry is string => typeof entry === 'string');
return urls.length > 0 ? urls : first;
} catch {
return first;
}
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { HStack } from '@astryxdesign/core/HStack';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { stockApi } from '@/api/stock';
import type { Product } from '@/api/types';
import { queryKeys } from '@/queries/keys';
import { money } from '@/features/store-admin/format';
import { effectivePrice, imagesOf } from '@/features/store-admin/productState';
import { Drawer } from '@/features/store-admin/Drawer';
import { Thumb } from './ui';
const QUICK = [10, 25, 50, 100] as const;
/**
* Ask head office for stock.
*
* A drawer rather than a modal, because every other detail surface on this
* console is one and a shop should not have to learn a second overlay.
*
* What the branch can say is a product and a quantity. That is not a design
* decision: `stockrequests` has no reason, no requester, no wanted-by date and
* no notes column, so a form offering them would be collecting text that goes
* nowhere. The request lands as Pending; the admin's Inventory queue picks it
* up.
*/
export function RequestDialog({
product,
initialQty,
tenantid,
locationid,
locationname,
onClose,
}: {
product: Product;
initialQty: number;
tenantid: number;
locationid: number;
locationname: string;
onClose: () => void;
}) {
const client = useQueryClient();
const [qty, setQty] = useState(Math.max(1, Math.round(initialQty) || 1));
const price = effectivePrice(product);
const ask = useMutation({
mutationFn: () =>
stockApi.create({
tenantid,
locationid,
productid: product.productid,
qty,
locationname,
productname: product.productname ?? '',
}),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: queryKeys.stock.all });
onClose();
},
});
return (
<Drawer
title="Ask for stock"
subtitle={product.productname ?? undefined}
width={420}
onClose={onClose}
>
<HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{product.productname ?? '—'}
</Text>
<Text type="body" size="xsm" color="secondary">
{[product.productbrand, product.productsku].filter(Boolean).join(' · ') || '—'}
</Text>
</VStack>
</HStack>
<VStack gap={1}>
<Text
type="label"
size="xsm"
color="secondary"
style={{ textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
How many
</Text>
<HStack gap={1} wrap="wrap">
{QUICK.map((value) => (
<Button
key={value}
label={String(value)}
variant={qty === value ? 'primary' : 'secondary'}
size="sm"
onClick={() => setQty(value)}
/>
))}
</HStack>
<NumberInput
label="Quantity"
size="sm"
min={1}
value={qty}
onChange={(value) => setQty(Math.max(1, Math.round(Number(value) || 1)))}
/>
</VStack>
{price > 0 ? (
<Text type="body" size="sm" color="secondary">
About <strong>{money(price * qty)}</strong> at your shop price of {money(price)} each.
</Text>
) : null}
{/* The honest description of what happens next. The backend has one
transition with behaviour — Received — and it credits the FULL
requested quantity. There is no approve-for-less, so a shop that asks
for 100 gets 100 or nothing. */}
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
This goes to your store administrator as a request. Nothing moves until they approve it and
you confirm the goods have arrived — and the whole quantity arrives together, since there is
no way to approve part of a request.
</Text>
{ask.isError ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
That did not go through. Check your connection and try again.
</Text>
) : null}
<HStack gap={1} justify="end" wrap="wrap">
<Button label="Cancel" variant="ghost" size="sm" onClick={onClose} />
<Button
label={ask.isPending ? 'Sending…' : 'Send request'}
variant="primary"
size="sm"
isDisabled={ask.isPending || qty < 1}
onClick={() => ask.mutate()}
/>
</HStack>
</Drawer>
);
}

View File

@@ -0,0 +1,140 @@
import { useMemo } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Download } from 'lucide-react';
import { Drawer } from '@/features/store-admin/Drawer';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { encodeQr, qrPath } from './qr';
/**
* The storefront QR, from the header.
*
* What it encodes is the whole story: the literal JSON `{"tenantid":N,
* "locationid":M}` and nothing else — not a URL, not the shop's name. The
* customer app reads those two numbers and opens this outlet's storefront,
* which is why the code is only correct for the branch that printed it.
*
* It opens in place rather than on a page of its own: this is looked at,
* printed once and closed, and a nav slot spent on it is a slot taken from
* something worked from all day.
*/
export function StoreQrDrawer({ onClose }: { onClose: () => void }) {
const { current, tenantid } = useBranchScope();
const payload = useMemo(
() => JSON.stringify({ tenantid, locationid: current?.locationid ?? 0 }),
[tenantid, current?.locationid],
);
const matrix = useMemo(() => encodeQr(payload), [payload]);
const path = useMemo(() => qrPath(matrix), [matrix]);
const where = [current?.suburb, current?.city].filter(Boolean).join(', ');
const quiet = 4;
const span = matrix.length + quiet * 2;
/**
* The print copy, drawn at 1024px into a canvas that never enters the page.
*
* The on-screen SVG would scale, but a poster printed from a 180px raster is
* soft, and a shop prints this once and lives with it.
*/
function download() {
const size = 1024;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) return;
const scale = size / span;
context.fillStyle = '#ffffff';
context.fillRect(0, 0, size, size);
context.fillStyle = '#000000';
for (let y = 0; y < matrix.length; y += 1) {
const row = matrix[y] as boolean[];
for (let x = 0; x < row.length; x += 1) {
if (!row[x]) continue;
// Ceil, not round: a fractional module edge left hairline gaps between
// neighbouring dark squares, which a scanner reads as a broken pattern.
context.fillRect(
Math.floor((x + quiet) * scale),
Math.floor((y + quiet) * scale),
Math.ceil(scale),
Math.ceil(scale),
);
}
}
const link = document.createElement('a');
link.download = `${slug(current?.locationname ?? 'store')}-qr.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
return (
<Drawer
title="Store QR code"
subtitle={current?.locationname ?? undefined}
width={400}
onClose={onClose}
>
<VStack gap={2} style={{ alignItems: 'center' }}>
<VStack gap={0} style={{ alignItems: 'center' }}>
<Text type="label" size="lg" weight="semibold">
{current?.locationname ?? 'Your store'}
</Text>
{where ? (
<Text type="body" size="sm" color="secondary">
{where}
</Text>
) : null}
</VStack>
<div
style={{
padding: 10,
borderRadius: 16,
background: '#ffffff',
border: '1px solid var(--color-line)',
lineHeight: 0,
}}
>
<svg
width={220}
height={220}
viewBox={`0 0 ${span} ${span}`}
role="img"
aria-label={`Storefront QR code for ${current?.locationname ?? 'this store'}`}
shapeRendering="crispEdges"
>
<rect width={span} height={span} fill="#ffffff" />
<g transform={`translate(${quiet} ${quiet})`}>
<path d={path} fill="#000000" />
</g>
</svg>
</div>
<Button
label="Download PNG"
variant="primary"
size="sm"
icon={<Download size={13} />}
onClick={download}
/>
</VStack>
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
Customers scan this in the Nearle app to shop <strong>this</strong> branch. Print it and
keep it on the counter — a code printed at another shop opens that shop, not yours.
</Text>
</Drawer>
);
}
function slug(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'store'
);
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react';
import { Monitor, QrCode, Store, UserCog, UserRound, Users } from 'lucide-react';
import { StoreQrDrawer } from './StoreQrDrawer';
import { AppShell, IconButton, type MenuEntry, type NavEntry } from '@/components/shell/AppShell';
import { useAuth } from '@/auth/AuthContext';
import { useTenantLocations } from '@/queries/hooks';
import { BranchScopeProvider, useBranchScope } from '@/features/store-admin/BranchScope';
/**
* Store user — one branch, and only that branch.
*
* The same four destinations as the Store Admin, for the same reason: the
* questions a shop asks in a day are what did we take, what do we sell, what is
* on the shelf, and how did the period go. What changes is the scope, not the
* shape — so the pages are the merchant's pages with the branch fixed, rather
* than a second set that drifts.
*
* The old console gave this role five nav entries over a six-tab hub, and one
* of them (Store Console) rendered another one (Products) inside itself. Four
* pages, each owning one question, replaces it.
*/
const NAV: readonly NavEntry[] = [
{ to: '/store/console', label: 'Console' },
{ to: '/store/products', label: 'Products' },
{ to: '/store/sales', label: 'Sales' },
{ to: '/store/reports', label: 'Reports' },
];
/**
* The account menu.
*
* Everything here is either about this person, or about the shop's fittings —
* reached when something needs checking, not worked from hour to hour. The
* merchant's "New branch" entry is deliberately absent: a shop does not
* commission outlets, and `createtenantlocation` also spawns a login.
*
* Customers sits here rather than in the nav because the four nav slots each
* answer a question about the trading day, and "who orders from us" is a
* lookup. Move it up if the shop uses it hourly.
*
* The store QR is NOT here — it is an icon in the header, because it opens in
* place and closes again. A destination is for somewhere you work; this is
* something you glance at and print.
*/
const MANAGE: readonly MenuEntry[] = [
{
to: '/store/customers',
label: 'Customers',
icon: <Users size={13} />,
note: 'Who orders from this shop',
},
{
to: '/store/terminals',
label: 'Terminals',
icon: <Monitor size={13} />,
note: 'Till health at this counter',
},
{
to: '/store/staff',
label: 'Counter staff',
icon: <UserCog size={13} />,
note: 'Who can open the till',
},
{
to: '/store/account',
label: 'My account',
icon: <UserRound size={13} />,
note: 'Profile and store binding',
},
];
/**
* Resolve the outlet this account is bound to.
*
* Two steps, and no third. `app_users.locationid` is the binding; a tenant with
* exactly one outlet needs no id at all. The old console had four more
* fallbacks below these — including matching the email's local part against a
* store's address, and falling back to `applocationid` — and those are guesses
* at an authorisation boundary. `applocationid` is a CITY id: passing one to a
* POS read returns an empty board, or another shop's takings if the numbers
* happen to collide.
*/
function useResolvedOutlet(): { locationid: number | null; isLoading: boolean } {
const { user } = useAuth();
const { data, isLoading } = useTenantLocations(user?.tenantid || undefined);
const branches = data ?? [];
if (user?.locationid && user.locationid > 0) return { locationid: user.locationid, isLoading };
if (isLoading) return { locationid: null, isLoading: true };
if (branches.length === 1 && branches[0]) return { locationid: branches[0].locationid, isLoading };
return { locationid: null, isLoading: false };
}
export function StoreUserShell() {
const { locationid, isLoading } = useResolvedOutlet();
const [isQrOpen, setQrOpen] = useState(false);
if (isLoading) return <Resolving />;
if (locationid === null) return <NoStore />;
return (
<BranchScopeProvider pin={locationid}>
<AppShell
nav={NAV}
home="/store/console"
navLabel="Store"
scopeControl={<BranchPill />}
manageItems={MANAGE}
headerActions={
<IconButton label="Store QR code" onClick={() => setQrOpen(true)}>
<QrCode size={16} />
</IconButton>
}
/>
{isQrOpen ? <StoreQrDrawer onClose={() => setQrOpen(false)} /> : null}
</BranchScopeProvider>
);
}
/**
* Where the merchant has a branch selector, the shop has a label.
*
* Same slot, same size, same position — so the two workspaces read as one
* product — but not a control, because there is nothing to choose. A disabled
* dropdown would say "you may not switch"; a label says "this is your shop",
* which is the true statement.
*/
function BranchPill() {
const { current, isLoading } = useBranchScope();
const label = isLoading ? 'Loading…' : (current?.locationname ?? 'Your store');
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
height: 32,
maxWidth: 230,
padding: '0 12px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
color: 'var(--color-ink-1)',
fontSize: 13,
fontWeight: 500,
}}
>
<Store size={14} style={{ color: 'var(--color-brand)', flex: 'none' }} />
<span
style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{label}
</span>
</span>
);
}
function Resolving() {
return (
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
<p style={{ color: 'var(--color-slate-500)', fontSize: 14 }}>Finding your store…</p>
</div>
);
}
/**
* The honest refusal.
*
* Every read on every page below is authorised by `locationid` alone, so a page
* that guessed one would not fail loudly — it would quietly show another shop's
* takings, or an empty board that reads as a quiet day.
*/
function NoStore() {
const { signOut } = useAuth();
return (
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
<div style={{ maxWidth: 440, textAlign: 'center' }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 8 }}>
No store assigned
</h1>
<p style={{ color: 'var(--color-slate-500)', fontSize: 14, lineHeight: 1.65 }}>
Your account is not linked to a branch yet, so there is nothing to show. Ask your store
administrator to allocate you to one.
</p>
<button
type="button"
onClick={signOut}
style={{
marginTop: 20,
height: 32,
padding: '0 14px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
fontSize: 13,
cursor: 'pointer',
}}
>
Sign out
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Mail, MapPin, Store, UserRound } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { useAuth } from '@/auth/AuthContext';
import { useBranchScope } from '@/features/store-admin/BranchScope';
/**
* My account.
*
* Read-only, because everything on it is. `PUT /users/update` is a GORM struct
* update — every zero value is skipped, so a field cannot be cleared — and it
* takes a bare `userid` with no tenant check. A form that silently drops half
* of what someone types is worse than no form, so the page says who to ask
* instead.
*/
export function StoreAccountPage() {
const { user } = useAuth();
const { current } = useBranchScope();
const where = [current?.suburb, current?.city].filter(Boolean).join(', ');
return (
<VStack gap={3}>
<PageHeader
title="My account"
count={current?.locationname}
description="Who you are signed in as, and the shop this account is tied to."
/>
<div style={{ maxWidth: 560 }}>
<Card padding={0} elevation="low">
<VStack gap={0} padding={0}>
<HStack gap={2} align="center" padding={3} style={{ borderBottom: '1px solid var(--color-line)' }}>
<span
style={{
width: 44,
height: 44,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
fontSize: 15,
fontWeight: 700,
}}
>
{initials(user?.name ?? '')}
</span>
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="lg" weight="semibold" maxLines={1}>
{user?.name ?? '—'}
</Text>
<Text type="body" size="sm" color="secondary">
Store user
</Text>
</VStack>
</HStack>
<VStack gap={1.5} padding={3}>
<Line icon={<Mail size={14} />} label="Email" value={user?.email || '—'} />
<Line icon={<UserRound size={14} />} label="User id" value={String(user?.userid ?? '—')} />
<Line
icon={<Store size={14} />}
label="Your shop"
value={current?.locationname ?? '—'}
/>
<Line icon={<MapPin size={14} />} label="Where" value={where || '—'} />
</VStack>
</VStack>
</Card>
</div>
</VStack>
);
}
function Line({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<HStack gap={1.5} align="center">
<span style={{ color: 'var(--color-ink-4)', flex: 'none' }}>{icon}</span>
<Text
type="label"
size="xsm"
color="secondary"
style={{ width: 96, flex: 'none', textTransform: 'uppercase', letterSpacing: '0.09em' }}
>
{label}
</Text>
<Text type="body" size="sm" style={{ minWidth: 0 }}>
{value}
</Text>
</HStack>
);
}
function initials(name: string): string {
return (
name
.split(' ')
.map((word) => word[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase() || 'NA'
);
}

View File

@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Mail, MapPin, Phone } from 'lucide-react';
import { customerLocality, customerName, type CustomerInfo } from '@/api/customers';
import { PageHeader } from '@/components/PageHeader';
import { useCustomers } from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { Drawer } from '@/features/store-admin/Drawer';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
/**
* Customers, for one shop.
*
* Read-only, and that is the whole surface. The old console's customer cards
* carried "Send promo SMS" and "Issue store credit" buttons; both were hidden
* from a store user by a `canManage` flag, and both were `toast()` calls with
* no endpoint behind them for anyone. There is nothing to port.
*
* The list is genuinely branch-scoped by the server — `gettenantcustomers`
* joins `tenantcustomers` on `locationid` — which makes it one of the few reads
* here that would still be correct if someone bypassed this client.
*/
export function StoreCustomersPage() {
const { current, tenantid } = useBranchScope();
const [keyword, setKeyword] = useState('');
const [open, setOpen] = useState<CustomerInfo | null>(null);
const customers = useCustomers(
tenantid && current ? { tenantid, locationid: current.locationid, pagesize: 200 } : undefined,
);
const rows = useMemo(() => {
const term = keyword.trim().toLowerCase();
const list = customers.data ?? [];
if (term === '') return list;
return list.filter((customer) =>
`${customerName(customer)} ${customer.contactno ?? ''} ${customer.email ?? ''} ${customer.address ?? ''}`
.toLowerCase()
.includes(term),
);
}, [customers.data, keyword]);
const withPhone = rows.filter((customer) => Boolean(customer.contactno)).length;
return (
<VStack gap={3}>
<PageHeader
title="Customers"
count={current?.locationname}
description="Everyone registered against this shop. Read-only — accounts are managed in the customer app."
actions={
<div style={{ width: 230 }}>
<TextInput
label="Search customers"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Name, phone, address…"
hasClear
/>
</div>
}
/>
{customers.isLoading ? (
<Note title="Reading your customer list…" />
) : rows.length === 0 ? (
<Note
title={keyword ? 'Nothing matches that search' : 'No customers yet'}
body={
keyword
? 'Clear the search to see the whole list.'
: 'A customer appears here once they have placed their first order from this shop.'
}
/>
) : (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary">
{count(rows.length)} customer{rows.length === 1 ? '' : 's'} · {count(withPhone)} with a
phone number
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
fontSize: 13,
}}
>
<colgroup>
<col style={{ width: '26%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '26%' }} />
<col style={{ width: '30%' }} />
</colgroup>
<thead>
<tr>
<Th>Customer</Th>
<Th>Phone</Th>
<Th>Email</Th>
<Th>Where</Th>
</tr>
</thead>
<tbody>
{rows.map((customer) => (
<Row key={customer.customerid} customer={customer} onOpen={() => setOpen(customer)} />
))}
</tbody>
</table>
</div>
</Card>
</VStack>
)}
{open ? <CustomerDrawer customer={open} onClose={() => setOpen(null)} /> : null}
</VStack>
);
}
function Row({ customer, onOpen }: { customer: CustomerInfo; onOpen: () => void }) {
const [isHovered, setIsHovered] = useState(false);
return (
<tr
onClick={onOpen}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
tabIndex={0}
role="button"
aria-label={`Open ${customerName(customer)}`}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
style={{
cursor: 'pointer',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
transition: 'background .15s',
}}
>
<Td>
<HStack gap={1.5} align="center">
<Initials name={customerName(customer)} />
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{customerName(customer)}
</Text>
</HStack>
</Td>
<Td isMuted>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{customer.contactno || '—'}</span>
</Td>
<Td isMuted>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{customer.email || '—'}
</span>
</Td>
<Td isMuted>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{customerLocality(customer)}
</span>
</Td>
</tr>
);
}
function CustomerDrawer({
customer,
onClose,
}: {
customer: CustomerInfo;
onClose: () => void;
}) {
const address = [
customer.doorno,
customer.address,
customer.landmark,
customer.suburb,
customer.city,
customer.state,
customer.postcode,
]
.filter(Boolean)
.join(', ');
return (
<Drawer
title={customerName(customer)}
subtitle={customerLocality(customer)}
width={420}
onClose={onClose}
>
<Card padding={0} elevation="low">
<VStack gap={1} padding={2}>
<Line icon={<Phone size={13} />} label="Phone" value={customer.contactno || '—'} />
<Line icon={<Mail size={13} />} label="Email" value={customer.email || '—'} />
<Line icon={<MapPin size={13} />} label="Address" value={address || '—'} />
</VStack>
</Card>
</Drawer>
);
}
function Line({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<HStack gap={1.5} align="start">
<span style={{ color: 'var(--color-ink-4)', flex: 'none', marginTop: 2 }}>{icon}</span>
<Text type="body" size="sm" color="secondary" style={{ width: 74, flex: 'none' }}>
{label}
</Text>
<Text type="body" size="sm" style={{ minWidth: 0 }}>
{value}
</Text>
</HStack>
);
}
function Initials({ name }: { name: string }) {
const initials =
name
.split(' ')
.map((word) => word[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase() || '—';
return (
<span
style={{
width: 32,
height: 32,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
fontSize: 12,
fontWeight: 700,
}}
>
{initials}
</span>
);
}
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
{body ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{body}
</Text>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,659 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { VStack } from '@astryxdesign/core/VStack';
import { Boxes, ClipboardList, PackageSearch } from 'lucide-react';
import { stockApi } from '@/api/stock';
import type { Product, StockRequest, StockStatementRow } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { queryKeys } from '@/queries/keys';
import {
useLocationProducts,
useStockRequests,
useStockStatement,
useTenantCategories,
} from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { ProductDrawer } from '@/features/store-admin/ProductDrawer';
import { count, money } from '@/features/store-admin/format';
import { effectivePrice, imagesOf, isPublished, stockOf } from '@/features/store-admin/productState';
import { RequestDialog } from '../RequestDialog';
import {
hasMoved,
latestByProduct,
REQUEST_COLOR,
REQUEST_LABEL,
requestState,
stockHealth,
} from '../requestState';
import { Chip, TabButton, Td, Th, Thumb } from '../ui';
type Tab = 'catalogue' | 'stock' | 'requests';
/**
* Products — what this shop may sell, what is on its shelf, and what it has
* asked for.
*
* The three tabs are the three questions in order, and they are the same three
* the old console had. What is different is what the branch may DO here, which
* is exactly one thing: ask for stock.
*
* It cannot price, publish or remove, and those are not hidden buttons — they
* are absent, because the endpoints behind them are tenant-wide.
* `publishproduct` reads the outlet list server-side and writes EVERY branch,
* then overwrites `products.retailprice` for the whole tenant; one shop pricing
* an item would re-price the chain. `deleteproductlocation` is a hard delete
* that loses the store price, `publishedat` and the ledger's key.
*/
export function StoreProductsPage() {
const { current, tenantid } = useBranchScope();
const locationid = current?.locationid;
const [tab, setTab] = useState<Tab>('catalogue');
const [category, setCategory] = useState('');
const [keyword, setKeyword] = useState('');
const [open, setOpen] = useState<Product | null>(null);
const [asking, setAsking] = useState<{ product: Product; qty: number } | null>(null);
const products = useLocationProducts(tenantid || undefined, locationid, 0);
const categories = useTenantCategories(tenantid || undefined);
const statement = useStockStatement(tenantid || undefined, locationid, { pagesize: 200 });
/**
* Every request this branch has raised, newest first, not filtered by date.
*
* The old console fetched one day at a time, which meant a chip on the
* catalogue card only knew about requests raised today — an item asked for
* yesterday looked untouched and got asked for twice.
*/
const requests = useStockRequests(
tenantid && locationid ? { tenantid, locationid, pagesize: 200 } : undefined,
);
const latest = useMemo(() => latestByProduct(requests.data ?? []), [requests.data]);
/**
* What the shop may sell.
*
* Filtered here, in the client, because the backend filters on nothing:
* `getlocationproducts` returns `publishedat` and no query in Fiesta has a
* `WHERE publishedat IS NOT NULL`. And `status` is not the flag — it is
* rewritten to available/outofstock on every stock movement.
*/
const catalogue = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (products.data ?? [])
.filter((product) => isPublished(product) && effectivePrice(product) > 0)
.filter((product) => !category || String(product.categoryid ?? '') === category)
.filter((product) =>
term === ''
? true
: `${product.productname ?? ''} ${product.productsku ?? ''} ${product.categoryname ?? ''}`
.toLowerCase()
.includes(term),
)
.sort((a, b) => (a.productname ?? '').localeCompare(b.productname ?? ''));
}, [products.data, category, keyword]);
const stockRows = useMemo(() => {
const term = keyword.trim().toLowerCase();
return (statement.data ?? [])
.filter(hasMoved)
.filter((row) =>
term === '' ? true : (row.productname ?? '').toLowerCase().includes(term),
)
.sort((a, b) => (a.closing ?? 0) - (b.closing ?? 0));
}, [statement.data, keyword]);
const requestRows = useMemo(
() =>
[...(requests.data ?? [])].sort((a, b) =>
(b.created ?? '').localeCompare(a.created ?? '') || b.requestid - a.requestid,
),
[requests.data],
);
const waiting = requestRows.filter((row) => {
const state = requestState(row);
return state === 'pending' || state === 'approved';
}).length;
const categoryOptions = useMemo(
() => [
{ value: '', label: 'All categories' },
...(categories.data ?? []).map((entry) => ({
value: String(entry.categoryid),
label: entry.categoryname,
})),
],
[categories.data],
);
return (
<VStack gap={3}>
<PageHeader
title="Products"
count={current?.locationname}
description="What your shop can sell, what is on the shelf, and what you have asked head office for."
actions={
tab === 'requests' ? null : (
<div style={{ width: 230 }}>
<TextInput
label="Search products"
isLabelHidden
size="sm"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
hasClear
/>
</div>
)
}
tabs={
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
<TabButton
label="Catalogue"
icon={<PackageSearch size={14} />}
isActive={tab === 'catalogue'}
onClick={() => setTab('catalogue')}
/>
<TabButton
label="My stock"
icon={<Boxes size={14} />}
isActive={tab === 'stock'}
onClick={() => setTab('stock')}
/>
<TabButton
label="My requests"
icon={<ClipboardList size={14} />}
isActive={tab === 'requests'}
onClick={() => setTab('requests')}
badge={waiting || undefined}
/>
</HStack>
}
/>
{tab === 'catalogue' ? (
<CataloguePanel
rows={catalogue}
isLoading={products.isLoading}
latest={latest}
categoryOptions={categoryOptions}
category={category}
onCategory={setCategory}
onOpen={setOpen}
onAsk={(product, qty) => setAsking({ product, qty })}
/>
) : tab === 'stock' ? (
<StockPanel rows={stockRows} isLoading={statement.isLoading} />
) : (
<RequestsPanel rows={requestRows} isLoading={requests.isLoading} />
)}
{open ? <ProductDrawer product={open} canManage={false} onClose={() => setOpen(null)} /> : null}
{asking && tenantid && locationid ? (
<RequestDialog
product={asking.product}
initialQty={asking.qty}
tenantid={tenantid}
locationid={locationid}
locationname={current?.locationname ?? ''}
onClose={() => setAsking(null)}
/>
) : null}
</VStack>
);
}
/* ── Catalogue ───────────────────────────────────────────────────────────── */
function CataloguePanel({
rows,
isLoading,
latest,
categoryOptions,
category,
onCategory,
onOpen,
onAsk,
}: {
rows: Product[];
isLoading: boolean;
latest: Map<number, StockRequest>;
categoryOptions: { value: string; label: string }[];
category: string;
onCategory: (next: string) => void;
onOpen: (product: Product) => void;
onAsk: (product: Product, qty: number) => void;
}) {
return (
<VStack gap={1.5}>
<HStack justify="between" align="center" gap={1} wrap="wrap">
<Text type="body" size="sm" color="secondary">
{isLoading
? 'Reading your catalogue…'
: `${count(rows.length)} product${rows.length === 1 ? '' : 's'} released to this shop`}
</Text>
<div style={{ width: 190 }}>
<Selector
label="Category"
isLabelHidden
size="sm"
value={category}
onChange={onCategory}
placeholder="All categories"
options={categoryOptions}
/>
</div>
</HStack>
{isLoading ? (
<Note title="Reading your catalogue…" />
) : rows.length === 0 ? (
<Note
title="Nothing released to this shop yet"
body="Your store administrator prices a product and releases it before it can be sold or requested here. Clear the category or the search if you were expecting rows."
/>
) : (
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
fontSize: 13,
}}
>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '23%' }} />
</colgroup>
<thead>
<tr>
<Th>Product</Th>
<Th>Category</Th>
<Th>Unit</Th>
<Th>Price</Th>
<Th>On shelf</Th>
<Th>Stock request</Th>
</tr>
</thead>
<tbody>
{rows.map((product) => (
<CatalogueRow
key={product.productid}
product={product}
request={latest.get(product.productid)}
onOpen={() => onOpen(product)}
onAsk={(qty) => onAsk(product, qty)}
/>
))}
</tbody>
</table>
</div>
</Card>
)}
</VStack>
);
}
/**
* One product, with its request state.
*
* The row opens the drawer on click, the same as the merchant's product table.
* The action inside stops propagation, or asking for stock would also open the
* drawer behind the dialog.
*/
function CatalogueRow({
product,
request,
onOpen,
onAsk,
}: {
product: Product;
request: StockRequest | undefined;
onOpen: () => void;
onAsk: (qty: number) => void;
}) {
const [isHovered, setIsHovered] = useState(false);
const state = requestState(request);
const stock = stockOf(product);
const unit = [product.unitvalue, product.productunit].filter(Boolean).join(' ');
return (
<tr
onClick={onOpen}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
tabIndex={0}
role="button"
aria-label={`Open ${product.productname ?? 'product'}`}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
style={{
cursor: 'pointer',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
transition: 'background .15s',
}}
>
<Td>
<HStack gap={1.5} align="center">
<Thumb images={imagesOf(product)} alt={product.productname ?? ''} />
<VStack gap={0} style={{ minWidth: 0 }}>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{product.productname ?? '—'}
</Text>
<span
style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--color-ink-4)' }}
>
{[product.productbrand, product.productsku].filter(Boolean).join(' · ') || '—'}
</span>
</VStack>
</HStack>
</Td>
<Td isMuted>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{product.categoryname || product.Subcategoryname || '—'}
</span>
</Td>
<Td isMuted>{unit || '—'}</Td>
<Td isStrong>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{money(effectivePrice(product))}</span>
</Td>
<Td isMuted={stock === 0}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(stock)}</span>
</Td>
<Td>
<HStack gap={1} justify="center" align="center" wrap="wrap">
{state && request ? (
<>
<Chip label={`${REQUEST_LABEL[state]} (${count(request.qty)})`} color={REQUEST_COLOR[state]} />
{state === 'pending' || state === 'rejected' || state === 'received' ? (
<Button
label={state === 'pending' ? 'Change' : 'Ask again'}
variant="secondary"
size="sm"
style={{ height: 26, fontSize: 12 }}
onClick={(event) => {
event?.stopPropagation?.();
onAsk(request.qty || 1);
}}
/>
) : null}
</>
) : (
<Button
label="Ask for stock"
variant="primary"
size="sm"
style={{ height: 26, fontSize: 12 }}
onClick={(event) => {
event?.stopPropagation?.();
onAsk(1);
}}
/>
)}
</HStack>
</Td>
</tr>
);
}
/* ── My stock ────────────────────────────────────────────────────────────── */
function StockPanel({ rows, isLoading }: { rows: StockStatementRow[]; isLoading: boolean }) {
if (isLoading) return <Note title="Reading the stock ledger…" />;
if (rows.length === 0) {
return (
<Note
title="Nothing stocked yet"
body="A product appears here once stock has actually moved for it. Ask for stock on the Catalogue tab, then confirm it as received when it arrives."
/>
);
}
return (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary">
{count(rows.length)} line{rows.length === 1 ? '' : 's'}
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
>
<colgroup>
<col style={{ width: '38%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '21%' }} />
</colgroup>
<thead>
<tr>
<Th>Product</Th>
<Th>Unit</Th>
<Th>Opening</Th>
<Th>In</Th>
<Th>Out</Th>
<Th>On shelf</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const closing = row.closing ?? 0;
const health = stockHealth(closing);
return (
<tr key={row.productid}>
<Td>
<HStack gap={1.5} align="center">
<Thumb
images={row.productimage ? [row.productimage] : []}
alt={row.productname ?? ''}
/>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{row.productname ?? '—'}
</Text>
</HStack>
</Td>
<Td isMuted>
{[row.unitvalue, row.productunit].filter(Boolean).join(' ') || '—'}
</Td>
<Td isMuted>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{count(row.opening ?? 0)}
</span>
</Td>
<Td isMuted>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{count(row.credit ?? 0)}
</span>
</Td>
<Td isMuted>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{count(row.debit ?? 0)}
</span>
</Td>
<Td>
<HStack gap={1} justify="center" align="center">
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{count(closing)}
</span>
<Chip label={health.label} color={health.color} />
</HStack>
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
/* ── My requests ─────────────────────────────────────────────────────────── */
function RequestsPanel({ rows, isLoading }: { rows: StockRequest[]; isLoading: boolean }) {
const client = useQueryClient();
const [done, setDone] = useState<Set<number>>(new Set());
/**
* Confirm the goods arrived.
*
* This flips the request to Received and NOTHING ELSE. The backend credits
* the ledger off that transition itself — the old console also posted a
* stock movement here, and every receipt counted twice: 20 units received
* read back as a credit of 40.
*
* The guard on the server is a read-then-write with no lock and no
* transaction, so a second call credits the ledger again. Hence the local
* `done` set on top of the pending flag: once a row has been confirmed in
* this session its button is gone, even before the list refetches.
*/
const receive = useMutation({
mutationFn: (requestid: number) => stockApi.approve(requestid),
onMutate: (requestid) => setDone((set) => new Set(set).add(requestid)),
onSettled: async () => {
await client.invalidateQueries({ queryKey: queryKeys.stock.all });
await client.invalidateQueries({ queryKey: queryKeys.products.all });
},
});
if (isLoading) return <Note title="Reading your requests…" />;
if (rows.length === 0) {
return (
<Note
title="You have not asked for anything yet"
body="Open the Catalogue tab and use Ask for stock. Your store administrator sees the request, approves it, and you confirm it here when the goods arrive."
/>
);
}
return (
<VStack gap={1.5}>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', fontSize: 13 }}
>
<colgroup>
<col style={{ width: '16%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<Th>Asked</Th>
<Th>Product</Th>
<Th>Qty</Th>
<Th>Status</Th>
<Th>Answered</Th>
<Th />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const state = requestState(row) ?? 'pending';
const isSettled = done.has(row.requestid) || state === 'received';
return (
<tr key={row.requestid}>
<Td isMuted>{stamp(row.created)}</Td>
<Td>
<HStack gap={1.5} align="center">
<Thumb
images={row.productimage ? [row.productimage] : []}
alt={row.productname ?? ''}
/>
<Text type="label" size="sm" weight="semibold" maxLines={1}>
{row.productname ?? `Product ${row.productid}`}
</Text>
</HStack>
</Td>
<Td isStrong>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{count(row.qty)}</span>
</Td>
<Td>
<Chip
label={REQUEST_LABEL[isSettled ? 'received' : state]}
color={REQUEST_COLOR[isSettled ? 'received' : state]}
/>
</Td>
<Td isMuted>{state === 'pending' ? '—' : stamp(row.updated)}</Td>
<Td>
{state === 'approved' && !isSettled ? (
<Button
label={receive.isPending ? 'Confirming…' : 'Confirm arrival'}
variant="primary"
size="sm"
style={{ height: 26, fontSize: 12 }}
isDisabled={receive.isPending}
onClick={() => receive.mutate(row.requestid)}
/>
) : null}
</Td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</VStack>
);
}
/* ── Bits ────────────────────────────────────────────────────────────────── */
function stamp(value: string | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' });
}
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
{body ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{body}
</Text>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,236 @@
import { useMemo, useState } from 'react';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { Plus } from 'lucide-react';
import type { PosUser } from '@/api/types';
import { PageHeader } from '@/components/PageHeader';
import { usePosRoles, usePosUsersByBranch, useStaffShifts } from '@/queries/hooks';
import { useBranchScope } from '@/features/store-admin/BranchScope';
import { TillDrawer } from '@/features/store-admin/PeopleDrawers';
import { count } from '@/features/store-admin/format';
import { Td, Th } from '../ui';
/**
* Counter staff — the people who can open this shop's till.
*
* Cashiers only. A supervisor runs the terminal and can create their own
* cashiers there, so issuing one is a merchant act; the backend takes the same
* line from the other side, where only roleid 7 may manage till users at all.
*
* Two things are worth knowing before reading the table. A till account has no
* console login — the backend leaves roles 7 and 8 out of every web lookup, so
* a cashier signing in here is not refused, they are simply not found. And
* there is no delete: deactivating stops the login and keeps their bills, which
* a delete would orphan.
*/
export function StoreStaffPage() {
const { current, tenantid } = useBranchScope();
const locationid = current?.locationid;
const [editing, setEditing] = useState<PosUser | null>(null);
const [isAdding, setAdding] = useState(false);
const pages = usePosUsersByBranch(tenantid || undefined, locationid ? [locationid] : []);
const roles = usePosRoles();
const shifts = useStaffShifts(tenantid || undefined, locationid);
const page = pages[0];
const rows = useMemo(() => {
const list = page?.data ?? [];
// Cashiers only. A supervisor may exist at this outlet — the merchant
// issued them — but they are not this page's to manage, and showing a row
// whose Edit button would be refused is worse than not showing it.
//
// No search box: a counter has a handful of staff, and a filter over four
// rows is a control that only ever gets in the way.
return list
.filter((person) => (person.role ?? '').toLowerCase() !== 'supervisor')
.sort((a, b) => (a.full_name ?? '').localeCompare(b.full_name ?? ''));
}, [page?.data]);
const active = rows.filter((person) => (person.status ?? '').toLowerCase() !== 'inactive').length;
return (
<VStack gap={3}>
<PageHeader
title="Counter staff"
count={current?.locationname}
description="Who can open the till at this counter. They sign in at the terminal, not here."
actions={
<Button
label="Add cashier"
variant="primary"
size="sm"
icon={<Plus size={14} />}
isDisabled={!tenantid || !locationid}
onClick={() => setAdding(true)}
/>
}
/>
{page?.isLoading ? (
<Note title="Reading your counter staff…" />
) : rows.length === 0 ? (
<Note
title="No cashiers yet"
body="Add one and they can open the till with their mobile number and PIN."
/>
) : (
<VStack gap={1.5}>
<Text type="body" size="sm" color="secondary">
{count(rows.length)} cashier{rows.length === 1 ? '' : 's'} · {count(active)} active
</Text>
<Card padding={0} elevation="low">
<div className="table-scroll">
<table
style={{
width: '100%',
borderCollapse: 'collapse',
tableLayout: 'fixed',
fontSize: 13,
}}
>
<colgroup>
<col style={{ width: '24%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<thead>
<tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Mobile</Th>
<Th>Shift</Th>
<Th>Status</Th>
<Th />
</tr>
</thead>
<tbody>
{rows.map((person) => (
<tr key={person.user_id}>
<Td isStrong>{person.full_name || '—'}</Td>
<Td>
<RoleChip label={person.role} />
</Td>
<Td isMuted>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{person.contactno || '—'}
</span>
</Td>
<Td isMuted>
{person.shift_name ? (
<>
<span style={{ display: 'block' }}>{person.shift_name}</span>
<span
style={{ display: 'block', fontSize: 11, color: 'var(--color-ink-4)' }}
>
{person.shift_start}–{person.shift_end}
</span>
</>
) : (
'Any'
)}
</Td>
<Td>
<StatusChip status={person.status} />
</Td>
<Td>
<Button
label="Edit"
variant="secondary"
size="sm"
onClick={() => setEditing(person)}
/>
</Td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</VStack>
)}
{(isAdding || editing) && tenantid && locationid ? (
<TillDrawer
row={editing}
tenantid={tenantid}
locationid={locationid}
roles={roles.data ?? []}
shifts={shifts.data ?? []}
cashierOnly
onClose={() => {
setAdding(false);
setEditing(null);
}}
/>
) : null}
</VStack>
);
}
function Note({ title, body }: { title: string; body?: string }) {
return (
<Card padding={0} elevation="low">
<VStack gap={0.5} padding={3}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
{body ? (
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.65 }}>
{body}
</Text>
) : null}
</VStack>
</Card>
);
}
/** The role chip, same shape and tone the merchant's till table uses. */
function RoleChip({ label }: { label: string | undefined }) {
const colour = 'var(--color-slate-500, #64748b)';
return (
<span
style={{
display: 'inline-flex',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
textTransform: 'capitalize',
whiteSpace: 'nowrap',
color: colour,
background: `color-mix(in oklab, ${colour} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${colour} 24%, transparent)`,
}}
>
{label || 'cashier'}
</span>
);
}
/** A dot and a word, not a pill — the merchant's page reads the same way. */
function StatusChip({ status }: { status: string | undefined }) {
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
fontSize: 12,
fontWeight: 500,
color: isActive ? 'var(--color-ink-2)' : 'var(--color-ink-4)',
}}
>
<span style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }} />
{isActive ? 'Active' : (status ?? 'Inactive')}
</span>
);
}

View File

@@ -0,0 +1,99 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { encodeQr, qrPath } from './qr';
/**
* The encoder, pinned.
*
* `GOLDEN` was produced by this encoder and then checked two ways: every
* version from 1 to 10 was compared module-for-module against the `qrcode`
* reference implementation at level Q, and the rendered output was decoded back
* to the original string with `jsQR`. Neither library is a dependency of this
* app — they were used once, to prove this file, which is what makes a golden
* matrix worth keeping. If it ever changes, the code stopped scanning.
*
* The mask is pinned in these tests because the encoder normally picks one by
* penalty score, and a change in the penalty rules would otherwise fail every
* assertion for a code that still scans perfectly.
*/
const GOLDEN = [
'11111110100010100011001111111',
'10000010111111001001101000001',
'10111010101100111010101011101',
'10111010101111100000001011101',
'10111010101100110001101011101',
'10000010000100110111001000001',
'11111110101010101010101111111',
'00000000101001000101100000000',
'01101011000010111110101011111',
'00101000000010010000101001011',
'10101011100000111110101110001',
'10001100100011101100100100001',
'01100010011011100111111000000',
'00110001010010011110111001011',
'10011110000101001110011000001',
'01000101101010001101010010010',
'01010111000101111111111001010',
'01000001010010110110111000101',
'10111011001110100010010100111',
'01100000110100111110111100010',
'10010111011111110111111110001',
'00000000101111100011100010011',
'11111110100001100111101010101',
'10000010010111100100100010000',
'10111010101011000110111110011',
'10111010011111101111000110111',
'10111010101001100011110111001',
'10000010100110110111111000010',
'11111110010100000111111110011',
];
const STOREFRONT = JSON.stringify({ tenantid: 1135, locationid: 1 });
test('encodes the storefront payload to the known-good matrix', () => {
const rows = encodeQr(STOREFRONT, 0).map((row) => row.map((cell) => (cell ? '1' : '0')).join(''));
assert.deepEqual(rows, GOLDEN);
});
test('the three finder patterns are where a scanner looks for them', () => {
const matrix = encodeQr(STOREFRONT);
const size = matrix.length;
for (const [row, col] of [
[0, 0],
[0, size - 7],
[size - 7, 0],
] as [number, number][]) {
// Dark ring, light inside it, dark 3x3 core.
assert.equal(matrix[row]?.[col], true, 'ring corner');
assert.equal(matrix[row + 1]?.[col + 1], false, 'light ring');
assert.equal(matrix[row + 3]?.[col + 3], true, 'core');
}
});
test('the separator around each finder stays light', () => {
const matrix = encodeQr(STOREFRONT);
const size = matrix.length;
for (let i = 0; i < 8; i += 1) {
assert.equal(matrix[7]?.[i], false, `separator row at ${i}`);
assert.equal(matrix[i]?.[7], false, `separator column at ${i}`);
assert.equal(matrix[7]?.[size - 1 - i], false, `top-right separator at ${i}`);
}
});
test('the dark module is set — it is the one module that is never data', () => {
const matrix = encodeQr(STOREFRONT);
assert.equal(matrix[matrix.length - 8]?.[8], true);
});
test('the version grows with the payload, and refuses what will not fit', () => {
assert.equal(encodeQr('x').length, 21, 'version 1');
assert.equal(encodeQr('a'.repeat(40)).length, 33, 'version 4');
assert.equal(encodeQr('a'.repeat(140)).length, 57, 'version 10');
assert.throws(() => encodeQr('a'.repeat(200)), /too long/);
});
test('the SVG path draws one square per dark module', () => {
const matrix = encodeQr(STOREFRONT);
const dark = matrix.flat().filter(Boolean).length;
assert.equal(qrPath(matrix).split('M').length - 1, dark);
});

View File

@@ -0,0 +1,537 @@
/**
* A QR encoder, in one file, with no dependency.
*
* The console needs exactly one QR: the storefront payload
* `{"tenantid":N,"locationid":M}` — 30-odd bytes, printed once and taped to a
* counter. Pulling a package in for that costs every developer on the team an
* `npm install` before the app will even start, which is what it cost the first
* time. This is byte mode at error-correction level Q, versions 1 to 10, which
* covers anything that payload can grow into (154 bytes at v10) with a third of
* the code readable in a sitting.
*
* Level Q on purpose: a code on a shop counter gets smudged, curled and
* photographed at an angle, and Q recovers from about a quarter of the pattern
* being unreadable. L would be smaller and would stop scanning first.
*
* Implemented from ISO/IEC 18004. Verified by decoding the rendered output —
* see `qr.test.ts`, which encodes a payload, rasterises it and reads it back.
*/
/** Data codewords and error-correction blocks per version, at level Q. */
const VERSIONS: {
version: number;
ecPerBlock: number;
/** [blockCount, dataCodewordsPerBlock] pairs — two groups at most. */
groups: [number, number][];
}[] = [
{ version: 1, ecPerBlock: 13, groups: [[1, 13]] },
{ version: 2, ecPerBlock: 22, groups: [[1, 22]] },
{ version: 3, ecPerBlock: 18, groups: [[2, 17]] },
{ version: 4, ecPerBlock: 26, groups: [[2, 24]] },
{ version: 5, ecPerBlock: 18, groups: [[2, 15], [2, 16]] },
{ version: 6, ecPerBlock: 24, groups: [[4, 19]] },
{ version: 7, ecPerBlock: 18, groups: [[2, 14], [4, 15]] },
{ version: 8, ecPerBlock: 22, groups: [[4, 18], [2, 19]] },
{ version: 9, ecPerBlock: 20, groups: [[4, 16], [4, 17]] },
{ version: 10, ecPerBlock: 24, groups: [[6, 19], [2, 20]] },
];
/** Alignment-pattern centre coordinates, by version. Version 1 has none. */
const ALIGNMENT: Record<number, number[]> = {
1: [],
2: [6, 18],
3: [6, 22],
4: [6, 26],
5: [6, 30],
6: [6, 34],
7: [6, 22, 38],
8: [6, 24, 42],
9: [6, 26, 46],
10: [6, 28, 50],
};
/** Level Q's two-bit code, as it appears in the format information. */
const EC_LEVEL_BITS = 0b11;
/* ── GF(256) ─────────────────────────────────────────────────────────────── */
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
{
let value = 1;
for (let i = 0; i < 255; i += 1) {
EXP[i] = value;
LOG[value] = i;
value <<= 1;
// x^8 + x^4 + x^3 + x^2 + 1 — the QR field's primitive polynomial.
if (value & 0x100) value ^= 0x11d;
}
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255] as number;
}
function mul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP[(LOG[a] as number) + (LOG[b] as number)] as number;
}
/** The generator polynomial for `degree` error-correction codewords. */
function generator(degree: number): number[] {
let poly = [1];
for (let i = 0; i < degree; i += 1) {
const next = new Array<number>(poly.length + 1).fill(0);
// Multiply by (x + a^i), coefficients ordered highest power first. The
// leading coefficient must stay 1 — the polynomial is monic — which is
// what fixes which of the two terms the a^i multiplies.
for (let j = 0; j < poly.length; j += 1) {
next[j] = (next[j] as number) ^ (poly[j] as number);
next[j + 1] = (next[j + 1] as number) ^ mul(poly[j] as number, EXP[i] as number);
}
poly = next;
}
return poly;
}
/** Reed-Solomon remainder — the error-correction codewords for one block. */
function ecCodewords(data: number[], count: number): number[] {
const gen = generator(count);
const remainder = new Array<number>(count).fill(0);
for (const byte of data) {
const factor = byte ^ (remainder[0] as number);
remainder.shift();
remainder.push(0);
if (factor !== 0) {
for (let i = 0; i < gen.length - 1; i += 1) {
remainder[i] = (remainder[i] as number) ^ mul(gen[i + 1] as number, factor);
}
}
}
return remainder;
}
/* ── Bit stream ──────────────────────────────────────────────────────────── */
class Bits {
private readonly bits: number[] = [];
push(value: number, length: number) {
for (let i = length - 1; i >= 0; i -= 1) this.bits.push((value >>> i) & 1);
}
get length() {
return this.bits.length;
}
/** Pad to a whole byte and return the codewords. */
toBytes(): number[] {
while (this.bits.length % 8 !== 0) this.bits.push(0);
const out: number[] = [];
for (let i = 0; i < this.bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j += 1) byte = (byte << 1) | (this.bits[i + j] as number);
out.push(byte);
}
return out;
}
}
/* ── Format and version information ──────────────────────────────────────── */
/** 15-bit format info: 5 data bits, BCH(15,5), XOR 0x5412. */
function formatInfo(mask: number): number {
const data = (EC_LEVEL_BITS << 3) | mask;
let value = data << 10;
for (let i = 4; i >= 0; i -= 1) {
if (value & (1 << (i + 10))) value ^= 0b10100110111 << i;
}
return ((data << 10) | value) ^ 0b101010000010010;
}
/** 18-bit version info, versions 7 and up: 6 data bits, BCH(18,6). */
function versionInfo(version: number): number {
// Degree 12, so the generator is 13 bits wide: x^12 + x^11 + x^10 + x^9 +
// x^8 + x^5 + x^2 + 1.
let value = version << 12;
for (let i = 5; i >= 0; i -= 1) {
if (value & (1 << (i + 12))) value ^= 0b1111100100101 << i;
}
return (version << 12) | value;
}
/* ── Matrix ──────────────────────────────────────────────────────────────── */
type Cell = 0 | 1 | null;
function blankMatrix(size: number): Cell[][] {
return Array.from({ length: size }, () => new Array<Cell>(size).fill(null));
}
function placeFinder(matrix: Cell[][], row: number, col: number) {
for (let r = -1; r <= 7; r += 1) {
for (let c = -1; c <= 7; c += 1) {
const y = row + r;
const x = col + c;
if (y < 0 || x < 0 || y >= matrix.length || x >= matrix.length) continue;
// The -1 and 7 lanes are the separator: always light. Without that
// guard the `r === 0` test below catches the separator row as part of
// the finder's ring and paints it dark.
const outside = r < 0 || r > 6 || c < 0 || c > 6;
const onRing = r === 0 || r === 6 || c === 0 || c === 6;
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
(matrix[y] as Cell[])[x] = !outside && (onRing || inCore) ? 1 : 0;
}
}
}
function reserved(size: number, version: number): boolean[][] {
const map = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
const mark = (y: number, x: number) => {
if (y >= 0 && x >= 0 && y < size && x < size) (map[y] as boolean[])[x] = true;
};
// Finders, their separators, and the format-information strips.
for (const [row, col] of [
[0, 0],
[0, size - 7],
[size - 7, 0],
] as [number, number][]) {
for (let r = -1; r <= 7; r += 1) for (let c = -1; c <= 7; c += 1) mark(row + r, col + c);
}
// The format information: nine cells along row 8 and nine down column 8 by
// the top-left finder, then EIGHT more on each of the other two — the second
// copy is 8 + 7 cells plus the dark module, not 9 + 9. Reserving one cell too
// many on each side steals two modules from the data stream and every byte
// after them lands one place out.
for (let i = 0; i < 9; i += 1) {
mark(8, i);
mark(i, 8);
}
for (let i = 0; i < 8; i += 1) {
mark(8, size - 1 - i);
mark(size - 1 - i, 8);
}
// Timing patterns.
for (let i = 0; i < size; i += 1) {
mark(6, i);
mark(i, 6);
}
// Alignment patterns, skipping the three that collide with the finders.
const centres = ALIGNMENT[version] ?? [];
for (const y of centres) {
for (const x of centres) {
const nearFinder =
(y === 6 && x === 6) ||
(y === 6 && x === size - 7) ||
(y === size - 7 && x === 6);
if (nearFinder) continue;
for (let r = -2; r <= 2; r += 1) for (let c = -2; c <= 2; c += 1) mark(y + r, x + c);
}
}
// Version information blocks.
if (version >= 7) {
for (let i = 0; i < 6; i += 1) {
for (let j = 0; j < 3; j += 1) {
mark(i, size - 11 + j);
mark(size - 11 + j, i);
}
}
}
return map;
}
function maskAt(mask: number, row: number, col: number): boolean {
switch (mask) {
case 0:
return (row + col) % 2 === 0;
case 1:
return row % 2 === 0;
case 2:
return col % 3 === 0;
case 3:
return (row + col) % 3 === 0;
case 4:
return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0;
case 5:
return ((row * col) % 2) + ((row * col) % 3) === 0;
case 6:
return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0;
default:
return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0;
}
}
/** The four penalty rules, summed. Lower is better. */
function penalty(matrix: Cell[][]): number {
const size = matrix.length;
const at = (y: number, x: number) => ((matrix[y] as Cell[])[x] === 1 ? 1 : 0);
let score = 0;
// Rule 1 — runs of five or more.
for (let i = 0; i < size; i += 1) {
for (const horizontal of [true, false]) {
let run = 1;
for (let j = 1; j < size; j += 1) {
const prev = horizontal ? at(i, j - 1) : at(j - 1, i);
const cur = horizontal ? at(i, j) : at(j, i);
if (cur === prev) {
run += 1;
} else {
if (run >= 5) score += 3 + (run - 5);
run = 1;
}
}
if (run >= 5) score += 3 + (run - 5);
}
}
// Rule 2 — 2x2 blocks of one colour.
for (let y = 0; y < size - 1; y += 1) {
for (let x = 0; x < size - 1; x += 1) {
const v = at(y, x);
if (v === at(y, x + 1) && v === at(y + 1, x) && v === at(y + 1, x + 1)) score += 3;
}
}
// Rule 3 — the finder-lookalike 1:1:3:1:1 pattern with four light modules.
const a = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const b = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if (x + 11 <= size) {
let matchA = true;
let matchB = true;
for (let k = 0; k < 11; k += 1) {
if (at(y, x + k) !== a[k]) matchA = false;
if (at(y, x + k) !== b[k]) matchB = false;
}
if (matchA) score += 40;
if (matchB) score += 40;
}
if (y + 11 <= size) {
let matchA = true;
let matchB = true;
for (let k = 0; k < 11; k += 1) {
if (at(y + k, x) !== a[k]) matchA = false;
if (at(y + k, x) !== b[k]) matchB = false;
}
if (matchA) score += 40;
if (matchB) score += 40;
}
}
}
// Rule 4 — deviation from an even split of dark and light.
let dark = 0;
for (let y = 0; y < size; y += 1) for (let x = 0; x < size; x += 1) dark += at(y, x);
const ratio = (dark * 100) / (size * size);
score += Math.floor(Math.abs(ratio - 50) / 5) * 10;
return score;
}
/**
* Encode a string as a QR matrix.
*
* Returns rows of booleans, `true` meaning a dark module, with no quiet zone —
* the renderer adds that, because how much white sits around the code is a
* layout decision, not an encoding one.
*/
export function encodeQr(text: string, forceMask?: number): boolean[][] {
// `forceMask` exists for the tests, which compare whole matrices against
// known-good ones and need the mask pinned rather than chosen by penalty.
const bytes = new TextEncoder().encode(text);
const chosen = VERSIONS.find((entry) => {
const dataCodewords = entry.groups.reduce((sum, [blocks, size]) => sum + blocks * size, 0);
return bytes.length + 2 <= dataCodewords;
});
if (!chosen) {
throw new Error(`QR payload too long: ${bytes.length} bytes, 152 is the maximum here`);
}
const dataCodewords = chosen.groups.reduce((sum, [blocks, size]) => sum + blocks * size, 0);
// Mode indicator (byte, 0100), length (8 bits for versions 1-9, 16 for 10+),
// the bytes themselves, then a terminator and the two alternating pad bytes.
const bits = new Bits();
bits.push(0b0100, 4);
bits.push(bytes.length, chosen.version >= 10 ? 16 : 8);
for (const byte of bytes) bits.push(byte, 8);
const capacityBits = dataCodewords * 8;
bits.push(0, Math.min(4, capacityBits - bits.length));
const codewords = bits.toBytes();
// The two pad bytes alternate, starting with 0xEC. `start` is captured
// because `codewords.length` changes as the loop pushes.
const start = codewords.length;
for (let i = start; i < dataCodewords; i += 1) {
codewords.push((i - start) % 2 === 0 ? 0xec : 0x11);
}
// Split into blocks, compute error correction per block, then interleave.
const dataBlocks: number[][] = [];
const ecBlocks: number[][] = [];
let offset = 0;
for (const [blockCount, blockSize] of chosen.groups) {
for (let i = 0; i < blockCount; i += 1) {
const block = codewords.slice(offset, offset + blockSize);
offset += blockSize;
dataBlocks.push(block);
ecBlocks.push(ecCodewords(block, chosen.ecPerBlock));
}
}
const interleaved: number[] = [];
const longest = Math.max(...dataBlocks.map((block) => block.length));
for (let i = 0; i < longest; i += 1) {
for (const block of dataBlocks) if (i < block.length) interleaved.push(block[i] as number);
}
for (let i = 0; i < chosen.ecPerBlock; i += 1) {
for (const block of ecBlocks) interleaved.push(block[i] as number);
}
const size = 17 + chosen.version * 4;
const isReserved = reserved(size, chosen.version);
const base = blankMatrix(size);
placeFinder(base, 0, 0);
placeFinder(base, 0, size - 7);
placeFinder(base, size - 7, 0);
for (let i = 8; i < size - 8; i += 1) {
const bit: Cell = i % 2 === 0 ? 1 : 0;
(base[6] as Cell[])[i] = bit;
(base[i] as Cell[])[6] = bit;
}
const centres = ALIGNMENT[chosen.version] ?? [];
for (const y of centres) {
for (const x of centres) {
const nearFinder =
(y === 6 && x === 6) || (y === 6 && x === size - 7) || (y === size - 7 && x === 6);
if (nearFinder) continue;
for (let r = -2; r <= 2; r += 1) {
for (let c = -2; c <= 2; c += 1) {
const ring = Math.max(Math.abs(r), Math.abs(c));
(base[y + r] as Cell[])[x + c] = ring === 1 ? 0 : 1;
}
}
}
}
// The dark module — always set, always at this one coordinate.
(base[size - 8] as Cell[])[8] = 1;
if (chosen.version >= 7) {
const info = versionInfo(chosen.version);
for (let i = 0; i < 18; i += 1) {
const bit: Cell = ((info >>> i) & 1) === 1 ? 1 : 0;
const row = Math.floor(i / 3);
const col = size - 11 + (i % 3);
(base[row] as Cell[])[col] = bit;
(base[col] as Cell[])[row] = bit;
}
}
// Data, in the zigzag: two columns at a time, right to left, skipping the
// timing column and anything already reserved.
const placed = base.map((row) => [...row]);
let index = 0;
let bitInByte = 7;
let upward = true;
for (let right = size - 1; right >= 1; right -= 2) {
// Column 6 is the vertical timing pattern. The pair that would straddle it
// shifts left by one and every pair after it follows — which is why `right`
// itself moves, rather than just the column read from it. Getting this
// wrong misplaces every module after the timing line and the code stops
// scanning altogether, which is exactly how it first failed.
if (right === 6) right = 5;
for (let step = 0; step < size; step += 1) {
const row = upward ? size - 1 - step : step;
for (const x of [right, right - 1]) {
if ((isReserved[row] as boolean[])[x]) continue;
let bit = 0;
if (index < interleaved.length) {
bit = ((interleaved[index] as number) >>> bitInByte) & 1;
bitInByte -= 1;
if (bitInByte < 0) {
bitInByte = 7;
index += 1;
}
}
(placed[row] as Cell[])[x] = bit === 1 ? 1 : 0;
}
}
upward = !upward;
}
// Try every mask, keep the least ugly.
let best: Cell[][] | null = null;
let bestScore = Infinity;
for (let mask = 0; mask < 8; mask += 1) {
if (forceMask !== undefined && mask !== forceMask) continue;
const candidate = placed.map((row) => [...row]);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if ((isReserved[y] as boolean[])[x]) continue;
if (maskAt(mask, y, x)) {
(candidate[y] as Cell[])[x] = (candidate[y] as Cell[])[x] === 1 ? 0 : 1;
}
}
}
writeFormat(candidate, mask);
const score = penalty(candidate);
if (score < bestScore) {
bestScore = score;
best = candidate;
}
}
return (best as Cell[][]).map((row) => row.map((cell) => cell === 1));
}
/**
* The format information, written into both of its copies.
*
* Bit order is the thing to get right and the thing every from-memory
* implementation gets wrong: the 15-bit string goes MSB first, starting at
* (8,0) for the copy around the top-left finder and at (size-1,8) for the split
* copy. Written LSB first it is a valid-looking code that no scanner reads,
* because the format tells the decoder the mask and the level.
*/
function writeFormat(matrix: Cell[][], mask: number) {
const size = matrix.length;
const info = formatInfo(mask);
const copyOne: [number, number][] = [
[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
[7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8],
];
const copyTwo: [number, number][] = [
[size - 1, 8], [size - 2, 8], [size - 3, 8], [size - 4, 8], [size - 5, 8],
[size - 6, 8], [size - 7, 8],
[8, size - 8], [8, size - 7], [8, size - 6], [8, size - 5], [8, size - 4],
[8, size - 3], [8, size - 2], [8, size - 1],
];
for (let i = 0; i < 15; i += 1) {
const bit: Cell = ((info >>> (14 - i)) & 1) === 1 ? 1 : 0;
const [y1, x1] = copyOne[i] as [number, number];
const [y2, x2] = copyTwo[i] as [number, number];
(matrix[y1] as Cell[])[x1] = bit;
(matrix[y2] as Cell[])[x2] = bit;
}
// The dark module. Always set, always here, never carrying information.
(matrix[size - 8] as Cell[])[8] = 1;
}
/** The matrix as one SVG path — one `<path>` beats hundreds of `<rect>`s. */
export function qrPath(matrix: boolean[][]): string {
const parts: string[] = [];
for (let y = 0; y < matrix.length; y += 1) {
const row = matrix[y] as boolean[];
for (let x = 0; x < row.length; x += 1) {
if (row[x]) parts.push(`M${x} ${y}h1v1h-1z`);
}
}
return parts.join('');
}

View File

@@ -0,0 +1,93 @@
import type { StockRequest } from '@/api/types';
/**
* Where a stock request has got to, from the branch's side.
*
* The backend has no state machine — `UpdateStockRequest(requestid, status)`
* writes whatever string it is handed, and exactly one value has behaviour:
* "Received" posts a stock movement for the full requested quantity. So these
* five buckets are a reading of the data, not an enum the server enforces, and
* anything unrecognised falls to `pending` rather than being hidden.
*/
export type RequestState = 'pending' | 'approved' | 'rejected' | 'received' | 'cancelled';
export const REQUEST_LABEL: Record<RequestState, string> = {
pending: 'Asked for',
approved: 'Approved',
rejected: 'Turned down',
received: 'Received',
cancelled: 'Cancelled',
};
export const REQUEST_COLOR: Record<RequestState, string> = {
pending: 'var(--color-warning, #b7860b)',
approved: 'var(--color-success, #10b981)',
rejected: 'var(--color-error, #d64545)',
received: 'var(--color-brand)',
cancelled: 'var(--color-ink-4)',
};
export function requestState(request: StockRequest | undefined): RequestState | undefined {
if (!request) return undefined;
const value = String(request.status ?? '').trim().toLowerCase();
if (value === 'received') return 'received';
if (value === 'approved') return 'approved';
if (value === 'rejected') return 'rejected';
if (value === 'cancelled' || value === 'canceled') return 'cancelled';
return 'pending';
}
/**
* The one request that speaks for a product.
*
* A branch can ask more than once for the same item, and the list comes back
* newest first. The newest row is the live one; the older ones are history, and
* showing a green "Received" chip because of last week's delivery would be a
* lie about this week's ask.
*/
export function latestByProduct(requests: readonly StockRequest[]): Map<number, StockRequest> {
const map = new Map<number, StockRequest>();
for (const request of requests) {
const seen = map.get(request.productid);
if (!seen || stamp(request) > stamp(seen)) map.set(request.productid, request);
}
return map;
}
function stamp(request: StockRequest): number {
const value = Date.parse(request.updated ?? request.created ?? '');
// No usable timestamp: fall back to the id, which is monotonic per insert.
return Number.isFinite(value) ? value : request.requestid;
}
/** Health of a stock line, on the same four steps the old console used. */
export function stockHealth(closing: number): { label: string; color: string } {
if (closing <= 0) return { label: 'Out of stock', color: 'var(--color-error, #d64545)' };
if (closing < 25) return { label: 'Critical', color: 'var(--color-error, #d64545)' };
if (closing < 120) return { label: 'Low', color: 'var(--color-warning, #b7860b)' };
return { label: 'Healthy', color: 'var(--color-success, #10b981)' };
}
/**
* Has stock ever actually moved for this line?
*
* `getstockstatement` returns a row for every product in the outlet's
* catalogue, so an item the admin released a minute ago comes back with all
* four columns at zero. That is a catalogue entry, not inventory. A line earns
* its place once anything has moved — and then keeps it, so a product that sold
* down to zero stays listed as "Out of stock" instead of disappearing on the
* day it matters most.
*/
export function hasMoved(row: {
opening?: number;
credit?: number;
debit?: number;
closing?: number;
}): boolean {
return (
(row.opening ?? 0) !== 0 ||
(row.credit ?? 0) !== 0 ||
(row.debit ?? 0) !== 0 ||
(row.closing ?? 0) !== 0
);
}

View File

@@ -0,0 +1,219 @@
import { useState, type ReactNode } from 'react';
import { ImageOff } from 'lucide-react';
/**
* The pieces every Store user page is built from.
*
* Copied — deliberately, and once — from the Store Admin pages rather than
* invented, so the two workspaces are the same product seen from two heights.
* The values are not arbitrary: 32px controls, radius 12, 13px labels, centred
* table cells and a soft chip in the row's own colour are the corrections the
* merchant pages already went through.
*/
/** A page tab, as used in the Store Admin's PageHeader tab row. */
export function TabButton({
label,
icon,
isActive,
onClick,
badge,
}: {
label: string;
icon: ReactNode;
isActive: boolean;
onClick: () => void;
badge?: number;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: isActive ? 600 : 500,
cursor: 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
{badge ? (
<span
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--color-warning, #b7860b)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'grid',
placeItems: 'center',
}}
>
{badge}
</span>
) : null}
</button>
);
}
/** A header action drawn like the tabs, so the two rows read as one bar. */
export function BarAction({
label,
icon,
onClick,
isDisabled,
}: {
label: string;
icon: ReactNode;
onClick: () => void;
isDisabled?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
disabled={isDisabled}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: 0,
background: isHovered && !isDisabled ? 'var(--color-surface-sunken)' : 'transparent',
color: isDisabled ? 'var(--color-ink-4)' : 'var(--color-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'background .2s, color .2s',
}}
>
{icon}
{label}
</button>
);
}
export function Th({ children }: { children?: ReactNode }) {
return (
<th
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid var(--color-line)',
fontSize: 12,
fontWeight: 600,
color: 'var(--color-ink-3)',
whiteSpace: 'nowrap',
}}
>
{children}
</th>
);
}
export function Td({
children,
isMuted,
isStrong,
}: {
children: ReactNode;
isMuted?: boolean;
isStrong?: boolean;
}) {
return (
<td
style={{
textAlign: 'center',
padding: '10px',
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
fontWeight: isStrong ? 600 : 400,
verticalAlign: 'middle',
}}
>
{children}
</td>
);
}
/** Soft fill, 26% border, in its own colour — the console's one status chip. */
export function Chip({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '2px 9px',
fontSize: 11.5,
fontWeight: 600,
whiteSpace: 'nowrap',
color,
background: `color-mix(in oklab, ${color} 12%, transparent)`,
border: `1px solid color-mix(in oklab, ${color} 26%, transparent)`,
}}
>
{label}
</span>
);
}
/**
* A 40px pack shot.
*
* `mix-blend-multiply` because catalogue photography is shot on white — without
* it every thumbnail carries a grey square where the photo's ground meets the
* row.
*/
export function Thumb({ images, alt }: { images: readonly string[]; alt: string }) {
const [failed, setFailed] = useState(false);
const src = images[0];
return (
<span
style={{
width: 40,
height: 40,
flex: 'none',
display: 'grid',
placeItems: 'center',
borderRadius: 10,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
overflow: 'hidden',
}}
>
{src && !failed ? (
<img
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
style={{ width: '100%', height: '100%', objectFit: 'contain', mixBlendMode: 'multiply' }}
/>
) : (
<ImageOff size={15} style={{ color: 'var(--color-line)' }} />
)}
</span>
);
}