diff --git a/src/features/catalogue/CatalogueDetailDrawer.tsx b/src/features/catalogue/CatalogueDetailDrawer.tsx index 7a8524b..7a72a08 100644 --- a/src/features/catalogue/CatalogueDetailDrawer.tsx +++ b/src/features/catalogue/CatalogueDetailDrawer.tsx @@ -1,17 +1,18 @@ import { useEffect, useState } from 'react'; import { Banner } from '@astryxdesign/core/Banner'; -import { Button } from '@astryxdesign/core/Button'; -import { Card } from '@astryxdesign/core/Card'; -import { Divider } from '@astryxdesign/core/Divider'; -import { HStack } from '@astryxdesign/core/HStack'; import { Lightbox } from '@astryxdesign/core/Lightbox'; import { Selector } from '@astryxdesign/core/Selector'; -import { Text } from '@astryxdesign/core/Text'; -import { Token } from '@astryxdesign/core/Token'; -import { VStack } from '@astryxdesign/core/VStack'; -import { Check, DownloadCloud, ImageOff, Info } from 'lucide-react'; +import { Check, DownloadCloud, ImageOff } from 'lucide-react'; import type { CatalogueProduct } from '@/api/types'; import { Drawer } from '@/features/store-admin/Drawer'; +import { + Badge, + Bullets, + DrawerButton, + DrawerCard, + Row, + Section, +} from '@/features/store-admin/drawerKit'; import { HealthScorePanel } from '@/features/store-admin/HealthScorePanel'; /** @@ -89,29 +90,35 @@ export function CatalogueDetailDrawer({ : undefined} width={540} onClose={onClose} + {...(isImported ? { meta: } : {})} + {...(!isImported && !blockedReason && onImport + ? { + isFooterFilled: true, + footer: ( + } + isDisabled={Boolean(isBusy)} + onClick={onImport} + /> + ), + } + : {})} > {/* The photograph, at the size a label can be read at. Click to zoom — at card size the ingredients and the net weight are not legible. */} - +
- - {images.length > 1 ? ( - - - - - - + {images.length > 1 ? ( + <> +
+ {images.map((src, index) => ( + + ))} +
+ {images.length} photos — only the first is imported -
-
-
- {images.map((src, index) => ( - - ))} -
-
- ) : null} + + + ) : null} +
{/* A RANGE, not a price. What the shop charges is set after the import, and conflating the two is how a catalogue figure ends up on a shelf. */} - - - - - MARKET PRICE RANGE - - + +
+ + Market price range + {product.price_range ?? '—'} - - - {product.size ? : null} - - + + + {product.size ? : null} +
+
{product.description ? ( - - - DESCRIPTION - - - {product.description} - - +
+

{product.description}

+
) : null} {/* ── Health score ────────────────────────────────────────────────── @@ -215,80 +202,50 @@ export function CatalogueDetailDrawer({ `brand` and `image_id` come straight off the catalogue row, so no lookup is needed to find the key. */} - + {product.highlights?.length || product.nutrients?.length ? ( -
+
{product.highlights?.length ? ( - - - HIGHLIGHTS - - - {product.highlights.map((line, index) => ( - - • {line} - - ))} - - +
+

Highlights

+ +
) : null} {product.nutrients?.length ? ( - - - NUTRITION - - - {product.nutrients.map((line, index) => ( - - • {line} - - ))} - - +
+

Nutrition

+ +
) : null}
) : null} {facts.length > 0 ? ( - <> - - +
+ {facts.map((fact) => ( - - - {fact.label} - - - {fact.value} - - + {fact.value} + ) : ( + fact.value + ) + } + /> ))} - - + +
) : null} {/* The action, last, because everything above is what the decision is - made on. */} + made on. The button itself lives in the fixed bar; what stays here is + the choice it depends on and the warning about what it does not do. */} {isImported ? ( ) : onImport ? ( - - - {categoryOptions ? ( - onCategoryChange?.(value)} - /* No "leave uncategorised" placeholder, and the advice that - used to sit here — "uncategorised beats wrongly categorised" - — is gone because it is false. A product filed under - category 0 is returned by NO query the customer app makes: - its endpoint rejects categoryid 0 outright. Seven products - across three tenants sat invisible that way, six outlets - showing shoppers an empty shop, while the console listed - their stock as normal. A wrongly filed product is at least - findable and fixable; an unfiled one was neither. */ - placeholder="Choose a category" - description="Your own category, not the catalogue's. A product with no category cannot appear in the customer app at all." - /> - ) : null} - - Adds this product with no price. It reaches no shop and cannot be sold until you - price and publish it. - - - +
+ + + +
+ {children} +
+ + {footer ? ( +
+ {footer} +
+ ) : null}
); diff --git a/src/features/store-admin/HealthScorePanel.tsx b/src/features/store-admin/HealthScorePanel.tsx index 9d2dd41..aaa35f3 100644 --- a/src/features/store-admin/HealthScorePanel.tsx +++ b/src/features/store-admin/HealthScorePanel.tsx @@ -1,9 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { Text } from '@astryxdesign/core/Text'; -import { VStack } from '@astryxdesign/core/VStack'; import { AlertTriangle, Check, ExternalLink, Leaf } from 'lucide-react'; import { nutritionApi } from '@/api/nutrition'; import type { Product } from '@/api/types'; +import { DrawerCard, Note, Row, Section } from './drawerKit'; import { BAND_COLOR, BAND_LABEL, facts, present } from './healthScore'; import './pages/deliveries.css'; @@ -57,24 +56,20 @@ export function HealthScorePanel({ product }: { product: Product }) { */ if (!brand || !imageId) { return ( - -
); } diff --git a/src/features/store-admin/ProductDrawer.tsx b/src/features/store-admin/ProductDrawer.tsx index ff8b273..06e3e63 100644 --- a/src/features/store-admin/ProductDrawer.tsx +++ b/src/features/store-admin/ProductDrawer.tsx @@ -1,17 +1,23 @@ 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 { BookOpen, EyeOff, Package, Ruler, Tag } 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 { + Badge, + Bullets, + DrawerButton, + DrawerCard, + Metric, + Metrics, + Note, + Row, + Section, +} from './drawerKit'; import { branchLabel, count, money } from './format'; import { HealthScorePanel } from './HealthScorePanel'; import { ProductSizes } from './ProductSizes'; @@ -59,6 +65,7 @@ export function ProductDrawer({ const { tenantid, current, scoped } = useBranchScope(); const client = useQueryClient(); const [confirmHide, setConfirmHide] = useState(false); + const [shot, setShot] = useState(0); const source = useCatalogueProduct(product.productbrand, product.productsku); const images = imagesOf(product); @@ -76,61 +83,82 @@ export function ProductDrawer({ }); + const price = effectivePrice(product); + const cost = product.productcost ?? 0; + /* Margin is derived, not stored. Shown only when both halves are real — a + margin against a missing cost is a number that looks like a fact. */ + const margin = price > 0 && cost > 0 ? price - cost : null; + return ( - - - } onClose={onClose} + meta={} + footer={ + canManage && isPublished(product) && !confirmHide ? ( + } + onClick={() => setConfirmHide(true)} + /> + ) : undefined + } > - {/* ── Money ─────────────────────────────────────────────────────── */} -
-
0 ? money(effectivePrice(product)) : 'not set'} - isStrong - /> -
-
-
+ {/* ── Money ───────────────────────────────────────────────────────── + Margin joins the three the spec lists, because "what do I make on + this" is the question the other three get asked in service of. */} + + 0 ? money(price) : 'not set'} /> + 0 ? money(cost) : '—'} isSmall /> + + {margin === null ? null : ( + + )} + - {reason ? ( - - {reason} - - ) : null} + {reason ? {reason} : null} - {/* ── Pack shots ────────────────────────────────────────────────── */} + {/* ── Pack shot ───────────────────────────────────────────────────── + One large image with thumbnails under it, rather than a row of four + equals: the first image is the pack a shopper meets, the rest are + angles of the same thing. */} {images.length > 0 ? ( - - - - {images.slice(0, 4).map((url) => ( - - ))} - - +
+
+ {product.productname +
+ {images.length > 1 ? ( +
+ {images.map((url, index) => ( + + ))} +
+ ) : null} +
) : null} - {/* ── Sizes ─────────────────────────────────────────────────────── */} - {/* Above "In your shops" because it changes what a SHOPPER meets, which + {/* ── Sizes ───────────────────────────────────────────────────────── + Above "In your shops" because it changes what a SHOPPER meets, which is the bigger fact about a product than where it is stocked. */} {tenantid && branch ? ( ) : null} - {/* ── Health score ──────────────────────────────────────────────── */} - {/* Under Sizes and above the shop detail: it is what a SHOPPER reads, + {/* ── Health score ────────────────────────────────────────────────── + Under Sizes and above the shop detail: it is what a SHOPPER reads, and it belongs with the other shopper-facing facts. */} - {/* ── In your shops ─────────────────────────────────────────────── */} - - - - - - - - - - - - {product.productdesc ? ( - - - - {product.productdesc} - - +
+ {/* Prose, not a labelled row — the section heading already says what + this is, and a row reading "Description: …" says it twice. */} +

{product.productdesc}

+
) : null} - {/* ── The fields the import left behind ─────────────────────────── */} + {/* ── In your shops ─────────────────────────────────────────────────── */} +
+ + } + /> + + } + /> + } + /> + +
+ + {/* ── The fields the import left behind ─────────────────────────────── */} {source.isLoading ? ( - - Looking up the full catalogue entry… - + Looking up the full catalogue entry… ) : source.data ? ( ) : ( - - - +
+ 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. - - + +
)} - {canManage && isPublished(product) ? ( - -
- {confirmHide ? ( - - {/* - 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. - */} - - This hides {product.productname} from your shops’ product - list. It does not 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. - - - + ); +} + +/** A text-weight action inside a section rather than in the footer. */ +export function LinkAction({ + label, + icon, + onClick, + isDisabled, +}: { + label: string; + icon?: ReactNode; + onClick: () => void; + isDisabled?: boolean; +}) { + return ( + + ); +} + +/* ── Bullets ─────────────────────────────────────────────────────────────── */ + +export function Bullets({ items }: { items: readonly string[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); +} diff --git a/src/features/store-admin/pages/UploadsPage.tsx b/src/features/store-admin/pages/UploadsPage.tsx index 0dca7d1..5ec6209 100644 --- a/src/features/store-admin/pages/UploadsPage.tsx +++ b/src/features/store-admin/pages/UploadsPage.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; import { VStack } from '@astryxdesign/core/VStack'; import { PageHeader } from '@/components/PageHeader'; import { useAuth } from '@/auth/AuthContext'; import { useBranchScope } from '../BranchScope'; import { UploadsPanel } from '@/features/uploads/UploadsPanel'; +import { SheetUploadDrawer } from '../SheetUploadDrawer'; /** * This merchant's uploads. @@ -19,6 +21,7 @@ import { UploadsPanel } from '@/features/uploads/UploadsPanel'; export function UploadsPage() { const { user } = useAuth(); const { selected } = useBranchScope(); + const [isUploading, setUploading] = useState(false); return ( @@ -28,8 +31,19 @@ export function UploadsPage() { /> setUploading(true)} {...(selected === null ? {} : { locationid: selected })} /> + {/* The same drawer Inventory opens. Uploading from the page that lists + uploads is the obvious place for it, and routing somebody to another + screen to press a second button is not a flow, it is a detour. */} + {isUploading ? ( + setUploading(false)} + /> + ) : null} ); } diff --git a/src/features/store-user/BulkRequestDialog.tsx b/src/features/store-user/BulkRequestDialog.tsx index 98c205f..75a0e2a 100644 --- a/src/features/store-user/BulkRequestDialog.tsx +++ b/src/features/store-user/BulkRequestDialog.tsx @@ -1,6 +1,5 @@ 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'; @@ -10,6 +9,7 @@ import { errorMessage } from '@/api/client'; import { stockApi } from '@/api/stock'; import type { Product } from '@/api/types'; import { Drawer } from '@/features/store-admin/Drawer'; +import { DrawerButton } from '@/features/store-admin/drawerKit'; import { queryKeys } from '@/queries/keys'; /** @@ -94,6 +94,30 @@ export function BulkRequestDialog({ subtitle={`${lines.length} product${lines.length === 1 ? '' : 's'}`} width={520} onClose={onClose} + isFooterSpread + footer={ + <> + + { + setProblem(null); + send.mutate(); + }} + /> + + } > @@ -162,19 +186,6 @@ export function BulkRequestDialog({ ) : null} - - + ))} +
+ +
+ + setSearch(event.target.value)} + /> + setFrom(event.target.value)} + /> + setTo(event.target.value)} />
+ + {/* ── The list ─────────────────────────────────────────────────────── */} + {shown.length === 0 ? ( + + + + Nothing matches + + + No upload matches this tab, search or date range. + + + + ) : ( + +
+ + + + + + + + + + + {paged.rows.map((receipt) => { + const batch = live[receipt.batchid]?.batch; + const state = stateOf(receipt, batch); + const totals = totalsOf(receipt, batch); + const colour = STATE_COLOUR[state]; + return ( + + + + + + + + ); + })} + +
File nameUploaded onRowsStatus +
+
+ + + + + + {receipt.filename || 'Spreadsheet'} + + + {totals.rows} row{totals.rows === 1 ? '' : 's'} + {totals.rejected > 0 ? ` · ${totals.rejected} rejected` : ''} + + +
+
+
{formatWhen(receipt.created)}
+
+ {[ + showTenant ? receipt.tenantname : null, + receipt.locationname, + receipt.uploadedname, + ] + .filter(Boolean) + .join(' · ')} +
+
{totals.rows || '—'} + + {STATE_LABEL[state]} + + {/* Shelving is a separate claim from ingestion and is + stated separately — a catalogue product carries no + price and no stock. */} + {receipt.shelvedat ? ( +
{receipt.shelvedcount} on the shelf
+ ) : null} +
+
+
+ +
+ )} + + {open ? ( + setDetail(null)} + {...(canShelveHere(open, live[open.batchid]?.batch) + ? { + extra: ( + void load()} + /> + ), + } + : {})} + /> + ) : null}
); } -/* ── One receipt ──────────────────────────────────────────────────────────── */ +/** The tabs, and the order they read in. */ +const TABS: { id: UploadTab; label: string }[] = [ + { id: 'all', label: 'All uploads' }, + { id: 'done', label: 'Successful' }, + { id: 'failed', label: 'Failed' }, + { id: 'running', label: 'In progress' }, +]; -function UploadRow({ - receipt, - live, - isExpanded, - onToggle, - onShelved, - showTenant, -}: { - receipt: UploadReceipt; - live: LiveReading | undefined; - isExpanded: boolean; - onToggle: () => void; - onShelved: () => void; - showTenant: boolean; -}) { - const batch = live?.batch; - const state = describeState(receipt, batch); - const expiresIn = daysUntilExpiry(receipt); - - return ( - - - - - - - - {receipt.filename || 'Spreadsheet'} - - - {state.line} - - - {[ - showTenant ? receipt.tenantname : null, - receipt.locationname, - receipt.uploadedname, - formatWhen(receipt.created), - ] - .filter(Boolean) - .join(' · ')} - - - - - - {/* The shelving half, stated separately and always. A product in the - global catalogue carries no price and no stock — "added" and - "on sale here" are two different claims, and running them - together would tell a shopkeeper they can sell something no - customer can buy. */} - {receipt.shelvedat ? ( - - ) : state.canShelve ? ( - - ) : null} -