backend integration started
This commit is contained in:
@@ -1,280 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
53
src/features/store-admin/CataloguePanel.tsx
Normal file
53
src/features/store-admin/CataloguePanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTenantCategories } from '@/queries/hooks';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
|
||||
/**
|
||||
* The Store Admin's half of the catalogue: browse it, add to your own products.
|
||||
*
|
||||
* A thin wrapper over the shared browser, and deliberately thin. What is
|
||||
* particular to this workspace is only the scope — the merchant is whoever is
|
||||
* signed in — and one click imports, because a merchant adding a product to
|
||||
* their own list has no pricing decision to make yet: the product lands under
|
||||
* "Not ready" and is priced there.
|
||||
*/
|
||||
export function CataloguePanel() {
|
||||
const { tenantid, current, branches } = useBranchScope();
|
||||
|
||||
/**
|
||||
* Import writes against one outlet, which the backend requires.
|
||||
*
|
||||
* With "All branches" selected there is no single destination, so the first
|
||||
* is used — and that is not a fudge: `PublishProduct` releases tenant-wide,
|
||||
* so the outlet on the import row decides where the product row is filed,
|
||||
* not who can sell it.
|
||||
*/
|
||||
const locationid = current?.locationid ?? branches[0]?.locationid;
|
||||
|
||||
const categories = useTenantCategories(tenantid);
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'Leave uncategorised' },
|
||||
...(categories.data ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
})),
|
||||
],
|
||||
[categories.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<CatalogueBrowser
|
||||
tenantid={tenantid || undefined}
|
||||
locationid={locationid}
|
||||
categoryOptions={categoryOptions}
|
||||
actionLabel="Add to my products"
|
||||
alignWithTabs
|
||||
{...(!tenantid || !locationid
|
||||
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -40,14 +39,10 @@ import {
|
||||
* 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;
|
||||
/** Owned by InventoryPage, so its button can sit in the tab row. */
|
||||
isUploading: boolean;
|
||||
onCloseUpload: () => void;
|
||||
}) {
|
||||
@@ -132,13 +127,6 @@ export function ProductsPanel({
|
||||
onClose={onCloseUpload}
|
||||
/>
|
||||
) : null}
|
||||
{isImporting && tenantid && branch ? (
|
||||
<CatalogueImportDrawer
|
||||
tenantid={tenantid}
|
||||
locationid={branch.locationid}
|
||||
onClose={onCloseImport}
|
||||
/>
|
||||
) : null}
|
||||
{publishing && tenantid ? (
|
||||
<PublishDialog
|
||||
products={publishing}
|
||||
|
||||
@@ -30,10 +30,11 @@ import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useStockRequests, useStockStatement } from '@/queries/hooks';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { CataloguePanel } from '../CataloguePanel';
|
||||
import { ProductsPanel } from '../ProductsPanel';
|
||||
import { branchLabel, count, money } from '../format';
|
||||
|
||||
type Tab = 'products' | 'stock' | 'requests';
|
||||
type Tab = 'products' | 'catalogue' | 'stock' | 'requests';
|
||||
|
||||
/**
|
||||
* Inventory — catalogue, stock and the approval queue.
|
||||
@@ -55,7 +56,6 @@ export function InventoryPage() {
|
||||
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(
|
||||
@@ -73,9 +73,14 @@ 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.
|
||||
/* Upload sheet lives here, not in the tab row, so its position is
|
||||
fixed — it no longer appears and disappears as the tab changes, and
|
||||
nothing below shifts sideways when it does.
|
||||
|
||||
"Import products" used to sit beside it and is gone: once browsing
|
||||
the catalogue became a tab, the button was a second door to a room
|
||||
already on the wall, and it read as a different action than the tab
|
||||
it opened.
|
||||
|
||||
The "N awaiting approval" badge that used to sit here is gone: the
|
||||
Stock requests tab already carries the same count, and a number
|
||||
@@ -89,12 +94,6 @@ export function InventoryPage() {
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setUploading(true)}
|
||||
/>
|
||||
<BarAction
|
||||
label="Import products"
|
||||
icon={<DownloadCloud size={14} />}
|
||||
isDisabled={!tenantid}
|
||||
onClick={() => setImporting(true)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
@@ -102,13 +101,19 @@ export function InventoryPage() {
|
||||
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 }}>
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Products"
|
||||
icon={<PackageSearch size={14} />}
|
||||
isActive={tab === 'products'}
|
||||
onClick={() => setTab('products')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Catalogue"
|
||||
icon={<DownloadCloud size={14} />}
|
||||
isActive={tab === 'catalogue'}
|
||||
onClick={() => setTab('catalogue')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Stock requests"
|
||||
icon={<ClipboardList size={14} />}
|
||||
@@ -128,11 +133,11 @@ export function InventoryPage() {
|
||||
|
||||
{tab === 'products' ? (
|
||||
<ProductsPanel
|
||||
isImporting={isImporting}
|
||||
onCloseImport={() => setImporting(false)}
|
||||
isUploading={isUploading}
|
||||
onCloseUpload={() => setUploading(false)}
|
||||
/>
|
||||
) : tab === 'catalogue' ? (
|
||||
<CataloguePanel />
|
||||
) : tab === 'requests' ? (
|
||||
<RequestsPanel requests={requests.data ?? []} isLoading={requests.isLoading} />
|
||||
) : (
|
||||
|
||||
@@ -55,8 +55,15 @@ const EMPTY: FormState = {
|
||||
contactno: '',
|
||||
address: '',
|
||||
suburb: '',
|
||||
city: 'Coimbatore',
|
||||
state: 'Tamil Nadu',
|
||||
// Empty, not pre-filled.
|
||||
//
|
||||
// These carried 'Coimbatore' and 'Tamil Nadu' as VALUES, not placeholders —
|
||||
// so a merchant anywhere else was submitted with the wrong city and state
|
||||
// unless somebody noticed and cleared them. A default that is right most of
|
||||
// the time is worse than a blank field, because it is only checked when it
|
||||
// looks wrong.
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
opentime: isoTime('08:00'),
|
||||
closetime: isoTime('22:00'),
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ReportsPage() {
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Revenue"
|
||||
icon={<IndianRupee size={14} />}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function SalesPage() {
|
||||
</HStack>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Online Orders"
|
||||
icon={<ShoppingCart size={14} />}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function UsersPage() {
|
||||
/>
|
||||
}
|
||||
tabs={
|
||||
<HStack gap={0.5} wrap="wrap" style={{ paddingTop: 12 }}>
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Store users"
|
||||
icon={<UserCog size={14} />}
|
||||
@@ -426,8 +426,16 @@ function Chip({ label, tone }: { label: string | undefined; tone: keyof typeof T
|
||||
|
||||
/** Active is quiet, anything else is loud — an inactive account is the exception. */
|
||||
function StatusChip({ status }: { status: string | undefined }) {
|
||||
const isActive = (status ?? 'active').toLowerCase().startsWith('active');
|
||||
const colour = isActive ? 'var(--color-success, #10b981)' : 'var(--color-ink-4)';
|
||||
// A missing status is UNKNOWN, not active. Defaulting it to active painted a
|
||||
// green dot and the word "Active" beside an account whose state the API never
|
||||
// reported — which is the one case where somebody most needs to look.
|
||||
const isActive = (status ?? '').toLowerCase().startsWith('active');
|
||||
const isUnknown = !status;
|
||||
const colour = isUnknown
|
||||
? 'var(--color-ink-4)'
|
||||
: isActive
|
||||
? 'var(--color-success, #10b981)'
|
||||
: 'var(--color-ink-4)';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
@@ -442,7 +450,7 @@ function StatusChip({ status }: { status: string | undefined }) {
|
||||
<span
|
||||
style={{ width: 6, height: 6, borderRadius: 999, background: colour, flex: 'none' }}
|
||||
/>
|
||||
{isActive ? 'Active' : (status ?? 'Inactive')}
|
||||
{isUnknown ? 'Unknown' : isActive ? 'Active' : status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,3 +292,19 @@ test('problems come back worst bucket first', () => {
|
||||
assert.equal(list[0]?.bucket, 'now');
|
||||
assert.equal(list.at(-1)?.bucket, 'look');
|
||||
});
|
||||
|
||||
/*
|
||||
* The regression this file exists to prevent repeating.
|
||||
*
|
||||
* `GET /pos/health/location` answers `{location_id, total, online, terminals}`.
|
||||
* It was typed as a bare array, so the first real sign-in hit `raw.map is not a
|
||||
* function` — with no error boundary in the app at the time, that rendered as a
|
||||
* blank page on the Store Admin console.
|
||||
*/
|
||||
test('a non-array from the health endpoint reads as no heartbeat, not a crash', () => {
|
||||
const wrapper = { location_id: '1', total: 2, online: 1, terminals: [till({}), till({})] };
|
||||
const summary = summariseBranch(wrapper as unknown as PosTerminalHealth[], NOW);
|
||||
assert.equal(summary.state, 'no-heartbeat');
|
||||
assert.equal(summary.total, 0);
|
||||
assert.equal(summary.terminals.length, 0);
|
||||
});
|
||||
|
||||
@@ -251,7 +251,10 @@ export function rankOf(state: TerminalState): number {
|
||||
* averaging is exactly how that hour goes unnoticed.
|
||||
*/
|
||||
export function summariseBranch(raw: PosTerminalHealth[], now: number): BranchSyncSummary {
|
||||
const terminals = raw.map((entry) => readTerminal(entry, now));
|
||||
// Defensive because this is a board on the landing page: an unexpected shape
|
||||
// from the health endpoint should read as "no heartbeat", not take the whole
|
||||
// console down with it. It already did once — see `PosLocationHealth`.
|
||||
const terminals = (Array.isArray(raw) ? raw : []).map((entry) => readTerminal(entry, now));
|
||||
|
||||
const worst = terminals.reduce<TerminalState>(
|
||||
(acc, terminal) => (RANK[terminal.state] > RANK[acc] ? terminal.state : acc),
|
||||
|
||||
Reference in New Issue
Block a user