store user login
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
280
src/features/store-admin/CatalogueImportDrawer.tsx
Normal file
280
src/features/store-admin/CatalogueImportDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/features/store-admin/Drawer.tsx
Normal file
135
src/features/store-admin/Drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
369
src/features/store-admin/ProductCard.tsx
Normal file
369
src/features/store-admin/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
src/features/store-admin/ProductDrawer.tsx
Normal file
362
src/features/store-admin/ProductDrawer.tsx
Normal 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’ 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>
|
||||
);
|
||||
}
|
||||
483
src/features/store-admin/ProductsPanel.tsx
Normal file
483
src/features/store-admin/ProductsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
src/features/store-admin/PublishDialog.tsx
Normal file
247
src/features/store-admin/PublishDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/features/store-admin/SheetUploadDrawer.tsx
Normal file
36
src/features/store-admin/SheetUploadDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal file
464
src/features/store-admin/offlineSales/CounterSalesDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/features/store-admin/offlineSales/salesSheet.test.ts
Normal file
163
src/features/store-admin/offlineSales/salesSheet.test.ts
Normal 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'));
|
||||
});
|
||||
555
src/features/store-admin/offlineSales/salesSheet.ts
Normal file
555
src/features/store-admin/offlineSales/salesSheet.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
103
src/features/store-admin/productState.ts
Normal file
103
src/features/store-admin/productState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user