backend integration started
This commit is contained in:
@@ -1,256 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { useProductCategories } from '@/queries/hooks';
|
||||
|
||||
export interface ImportDrawerProps {
|
||||
product: CatalogueProduct;
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
isSaving: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onSubmit: (row: ImportCatalogueProductRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five things the catalogue cannot supply.
|
||||
*
|
||||
* The global catalogue carries a price RANGE and a free-text category, and
|
||||
* neither maps onto a tenant's own pricing or category tree. So category,
|
||||
* subcategory, retail price, cost and tax are collected here before Import can
|
||||
* be enabled — an import with them missing produces a product a store cannot
|
||||
* legally sell.
|
||||
*/
|
||||
export function ImportDrawer({
|
||||
product,
|
||||
tenantid,
|
||||
locationid,
|
||||
isSaving,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ImportDrawerProps) {
|
||||
const { data: categories } = useProductCategories(tenantid);
|
||||
|
||||
const [categoryid, setCategoryid] = useState('');
|
||||
const [subcategoryid, setSubcategoryid] = useState('');
|
||||
/**
|
||||
* Seeded from the bottom of the catalogue's price range.
|
||||
*
|
||||
* A guess, and a better starting point than an empty box — the operator
|
||||
* changes it or accepts it, but does not have to go and look the product up.
|
||||
* The range is a display string ("₹33-37"), so the number is parsed out of
|
||||
* it rather than read from a field.
|
||||
*/
|
||||
const [retailprice, setRetailprice] = useState<number | null>(() => priceFloor(product.price_range));
|
||||
const [productcost, setProductcost] = useState<number | null>(null);
|
||||
const [taxpercent, setTaxpercent] = useState<number | null>(0);
|
||||
const [quantity, setQuantity] = useState<number | null>(0);
|
||||
|
||||
const categoryOptions = (categories ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}));
|
||||
|
||||
const isComplete =
|
||||
categoryid !== '' &&
|
||||
typeof retailprice === 'number' &&
|
||||
retailprice > 0 &&
|
||||
typeof productcost === 'number' &&
|
||||
typeof taxpercent === 'number';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
className="dialog-panel"
|
||||
padding={0}
|
||||
>
|
||||
<DialogHeader title="Add to store" subtitle="Set what this store will sell it for" onOpenChange={(open) => (open ? undefined : onClose())} hasDivider />
|
||||
<VStack gap={3} padding={3}>
|
||||
<HStack gap={2} align="start">
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 12,
|
||||
background: 'var(--color-slate-50)',
|
||||
flex: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<VStack gap={0.5}>
|
||||
<Text type="large" weight="semibold">
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{product.brand} · id {product.id} · {product.size ?? '—'}
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Catalogue range {product.price_range ?? '—'} — a guide, not a price. What you set below
|
||||
is what the store sells at.
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<CatalogueFacts product={product} />
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
options={categoryOptions}
|
||||
value={categoryid}
|
||||
onChange={setCategoryid}
|
||||
placeholder="Select category…"
|
||||
description="The tenant's own category, not the catalogue's."
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Subcategory"
|
||||
options={[{ value: '', label: 'None' }]}
|
||||
value={subcategoryid}
|
||||
onChange={setSubcategoryid}
|
||||
placeholder="Select subcategory…"
|
||||
isDisabled={categoryid === ''}
|
||||
/>
|
||||
<NumberInput label={<span>Retail price (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={retailprice} onChange={setRetailprice} min={0} />
|
||||
<NumberInput label={<span>Cost (₹) <span style={{ color: 'var(--color-error)' }}>*</span></span> as any} value={productcost} onChange={setProductcost} min={0} />
|
||||
<NumberInput label="Tax (%)" value={taxpercent} onChange={setTaxpercent} min={0} max={100} />
|
||||
<NumberInput
|
||||
label="Opening stock"
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={0}
|
||||
description="Re-importing adds to this rather than replacing it."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<HStack
|
||||
align="center"
|
||||
gap={1}
|
||||
padding={2}
|
||||
style={{
|
||||
background: 'var(--color-error-muted, #fceeee)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--color-error, #d64545)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<Text type="body" size="sm" style={{ color: 'inherit' }}>
|
||||
{errorMessage(error)}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="end" gap={1.5}>
|
||||
<Button label="Cancel" variant="secondary" onClick={onClose} />
|
||||
<Button
|
||||
label={isSaving ? 'Adding…' : 'Add to store'}
|
||||
variant="primary"
|
||||
isLoading={isSaving}
|
||||
isDisabled={!isComplete}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
tenantid,
|
||||
locationid,
|
||||
brand: product.brand,
|
||||
catalogueid: product.id,
|
||||
categoryid: Number(categoryid),
|
||||
subcategoryid: subcategoryid === '' ? 0 : Number(subcategoryid),
|
||||
quantity: quantity ?? 0,
|
||||
stocktype: 'in',
|
||||
status: 'available',
|
||||
retailprice: retailprice ?? 0,
|
||||
productcost: productcost ?? 0,
|
||||
taxpercent: taxpercent ?? 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lower bound of a catalogue price range.
|
||||
*
|
||||
* "₹33-37" → 33. It is a display string with no fixed shape, so the first
|
||||
* number in it is taken and anything unparseable gives null rather than a
|
||||
* fabricated price.
|
||||
*/
|
||||
function priceFloor(range: string | undefined): number | null {
|
||||
if (!range) return null;
|
||||
const match = range.replace(/,/g, '').match(/\d+(\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const value = Number(match[0]);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the catalogue knows and the import does not carry.
|
||||
*
|
||||
* The import copies seven of nineteen fields. These are among the twelve it
|
||||
* leaves behind, and they are the ones that decide whether this is the right
|
||||
* product: the licence, who sells it, and what is in it. Shown here because
|
||||
* after the import they are only reachable by going back to the catalogue.
|
||||
*/
|
||||
function CatalogueFacts({ product }: { product: CatalogueProduct }) {
|
||||
const facts: [string, string][] = [];
|
||||
if (product.category) facts.push(['Catalogue category', product.category]);
|
||||
if (product.product_sku) facts.push(['SKU', product.product_sku]);
|
||||
if (product.fssai_license) facts.push(['FSSAI licence', product.fssai_license]);
|
||||
if (product.providers?.length) facts.push(['Sold on', product.providers.join(', ')]);
|
||||
if ((product.images?.length ?? 0) > 1) {
|
||||
facts.push(['Photos', `${product.images?.length} — only the first is imported`]);
|
||||
}
|
||||
|
||||
if (facts.length === 0 && !product.highlights?.length) return null;
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
{facts.length > 0 ? (
|
||||
<VStack gap={0.5}>
|
||||
{facts.map(([label, value]) => (
|
||||
<HStack key={label} justify="between" align="start" gap={2}>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ flex: 'none' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" style={{ textAlign: 'right' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{product.highlights?.length ? (
|
||||
<VStack gap={0}>
|
||||
{product.highlights.slice(0, 4).map((line) => (
|
||||
<Text key={line} type="body" size="xsm" color="secondary" style={{ lineHeight: 1.55 }}>
|
||||
• {line}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,294 +1,171 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
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, PackagePlus, Upload } from 'lucide-react';
|
||||
import { catalogueKey } from '@/api/catalogue';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { CatalogueProduct, ImportCatalogueProductRequest } from '@/api/types';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import {
|
||||
useCatalogueBrands,
|
||||
useCatalogueProducts,
|
||||
useImportedRefs,
|
||||
useTenants,
|
||||
} from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { ImportDrawer } from '../import/ImportDrawer';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/**
|
||||
* The global catalogue browser, and the place both import paths start.
|
||||
* The platform operator's catalogue.
|
||||
*
|
||||
* The list loads with NO brand filter — brand, category and keyword are filters
|
||||
* applied on top of an already-visible list, not a prerequisite to seeing one.
|
||||
* Products already imported by the selected tenant are badged rather than
|
||||
* hidden, because re-importing tops up stock and is a legitimate thing to do.
|
||||
* TWO MODES, AND THEY ASK FOR DIFFERENT THINGS.
|
||||
*
|
||||
* - **Browse** is reading. The operator looks at what the FMCG catalogue holds
|
||||
* — the photographs, the pack sizes, the FSSAI licences, what is stocked
|
||||
* where — and nothing is written. There is no merchant to choose because
|
||||
* nothing lands anywhere, so the page is the rail, the search and the grid
|
||||
* and nothing else. The per-product Add is gone from here: stocking one
|
||||
* shop at a time is the merchant's own job, in Store Admin ▸ Inventory ▸
|
||||
* Catalogue.
|
||||
*
|
||||
* - **From spreadsheet** is writing, and it cannot work without a target —
|
||||
* the import endpoint rejects a row with no `tenantid` or `locationid`. So
|
||||
* the merchant and outlet pickers live in that mode, where the question is
|
||||
* actually being asked, rather than sitting above a grid that never uses
|
||||
* them.
|
||||
*
|
||||
* The grid, the filters, the cards and the detail drawer are the shared
|
||||
* `CatalogueBrowser` — the same ones the Store Admin sees, because it is the
|
||||
* same catalogue.
|
||||
*/
|
||||
export function GlobalCataloguePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [brand, setBrand] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selected, setSelected] = useState<CatalogueProduct | null>(null);
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
const { data: brands } = useCatalogueBrands();
|
||||
const {
|
||||
data: products,
|
||||
isLoading,
|
||||
error,
|
||||
} = useCatalogueProducts({ brand: brand || undefined, keyword: keyword || undefined, pagesize: 48 });
|
||||
const { data: importedRefs } = useImportedRefs(tenantid);
|
||||
|
||||
/** `brand:catalogueid` — both halves, because a bare id repeats across brands. */
|
||||
const importedKeys = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const ref of importedRefs ?? []) set.add(catalogueKey(ref));
|
||||
return set;
|
||||
}, [importedRefs]);
|
||||
/**
|
||||
* The outlets of the chosen merchant, read separately and deliberately.
|
||||
*
|
||||
* `getalltenants` is `SELECT * FROM tenants` with no join to
|
||||
* `tenantlocations` (`tenantRepository.go:79-133`), so its `locationid` is the
|
||||
* struct's zero value on every row. Seeding the destination from it — which
|
||||
* this page used to do — meant the id was always 0 and every upload stayed
|
||||
* blocked behind "pick a merchant" for a merchant already picked.
|
||||
*/
|
||||
const locations = useTenantLocations(tenantid);
|
||||
|
||||
const tenantOptions = useMemo(() => {
|
||||
if (!tenants) return [];
|
||||
const seen = new Map<number, { name: string; locationid: number }>();
|
||||
const seen = new Map<number, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) {
|
||||
seen.set(tenant.tenantid, { name: tenant.tenantname, locationid: tenant.locationid });
|
||||
}
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, value]) => ({ value: String(id), label: value.name }));
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
const brandOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All brands' },
|
||||
...(brands ?? []).map((entry) => ({
|
||||
value: entry.brand,
|
||||
label: `${entry.brand} (${entry.product_count})`,
|
||||
const locationOptions = useMemo(
|
||||
() =>
|
||||
(locations.data ?? []).map((outlet) => ({
|
||||
value: String(outlet.locationid),
|
||||
label: outlet.locationname || `Outlet ${outlet.locationid}`,
|
||||
})),
|
||||
],
|
||||
[brands],
|
||||
[locations.data],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (rows: ImportCatalogueProductRequest[]) => productsApi.importFromCatalogue(rows),
|
||||
onSuccess: async () => {
|
||||
// Both the imported-refs lookup and the store catalogue go stale together.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.catalogue.all }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.products.all }),
|
||||
]);
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
const tenant = (tenants as TenantInfo[] | undefined)?.find(
|
||||
(entry) => String(entry.tenantid) === value,
|
||||
);
|
||||
if (tenant) next.set('locationid', String(tenant.locationid));
|
||||
// Cleared rather than carried: the outlets for this merchant have not
|
||||
// loaded yet, and keeping the previous one's would aim an upload at another
|
||||
// merchant's shop.
|
||||
next.delete('locationid');
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
function chooseLocation(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
/** Most merchants have one outlet; picking it from a list of one teaches nothing. */
|
||||
useEffect(() => {
|
||||
if (!tenantid || locationid) return;
|
||||
const first = locations.data?.[0];
|
||||
if (first) chooseLocation(String(first.locationid));
|
||||
// `params` is read through the setter, so it is not a dependency here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantid, locationid, locations.data]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Global catalogue"
|
||||
count={products ? `${products.length} shown` : undefined}
|
||||
description="Browse the FMCG catalogue and stock a store from it — or upload the tenant's own product list instead."
|
||||
description="Read the FMCG catalogue, or upload a merchant's own product list."
|
||||
actions={
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="From catalogue"
|
||||
variant={mode === 'catalogue' ? 'primary' : 'secondary'}
|
||||
icon={<PackagePlus size={15} />}
|
||||
onClick={() => setMode('catalogue')}
|
||||
/>
|
||||
<Button
|
||||
label="From spreadsheet"
|
||||
variant={mode === 'sheet' ? 'primary' : 'secondary'}
|
||||
icon={<Upload size={15} />}
|
||||
onClick={() => setMode('sheet')}
|
||||
/>
|
||||
</HStack>
|
||||
<SegmentedControl
|
||||
label="What to do"
|
||||
value={mode}
|
||||
onChange={(value) => setMode(value as 'catalogue' | 'sheet')}
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="catalogue" label="Browse catalogue" icon={<PackageSearch size={14} />} />
|
||||
<SegmentedControlItem value="sheet" label="Upload sheet" icon={<Upload size={14} />} />
|
||||
</SegmentedControl>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Which store is being stocked. Every import call needs both ids, so this
|
||||
is asked once, up front, rather than per product. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader title="Stocking" />
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Tenant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select tenant…"
|
||||
hasSearch
|
||||
/>
|
||||
<TextInput
|
||||
label="Location id"
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={(value) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('locationid', value);
|
||||
setParams(next, { replace: true });
|
||||
}}
|
||||
description="Defaults to the tenant's first outlet."
|
||||
/>
|
||||
</div>
|
||||
{!hasTarget ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Pick a tenant before importing — every import call is scoped to a tenant and a
|
||||
location, and the backend rejects one without both.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 190 }}>
|
||||
<Selector
|
||||
label="Brand"
|
||||
isLabelHidden
|
||||
options={brandOptions}
|
||||
value={brand}
|
||||
onChange={setBrand}
|
||||
placeholder="All brands"
|
||||
hasSearch
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 240 }}>
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
placeholder="Search catalogue…"
|
||||
hasClear
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isEmpty={(products ?? []).length === 0}
|
||||
emptyTitle="Nothing matches those filters"
|
||||
emptyDescription="Clear the brand or keyword to see the full catalogue."
|
||||
>
|
||||
<div className="card-grid">
|
||||
{(products ?? []).map((product) => {
|
||||
const isImported = importedKeys.has(catalogueKey(product));
|
||||
return (
|
||||
<Card key={`${product.brand}:${product.id}`} padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: 'var(--color-slate-50)',
|
||||
borderRadius: 12,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.images?.[0] ? (
|
||||
<img
|
||||
src={product.images[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<PackagePlus size={26} style={{ color: 'var(--color-slate-300)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={2}>
|
||||
{product.product_name}
|
||||
</Text>
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{product.brand} · {product.size ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack justify="between" align="center">
|
||||
{/* A RANGE, not a price. The store sets the real one at import. */}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{product.price_range ?? '—'}
|
||||
</Text>
|
||||
{isImported ? (
|
||||
<Badge variant="success" label="Imported" icon={<Check size={11} />} />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
label={isImported ? 'Top up stock' : 'Add to store'}
|
||||
variant={isImported ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
width="100%"
|
||||
isDisabled={!hasTarget}
|
||||
onClick={() => setSelected(product)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
<VStack gap={2}>
|
||||
{/* Only here. An upload is written against one merchant and one
|
||||
outlet, and the endpoint refuses a row without both. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={2}>
|
||||
<SectionHeader
|
||||
title="Uploading for"
|
||||
note="Every row is written against one merchant and one outlet."
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Merchant"
|
||||
options={tenantOptions}
|
||||
value={tenantid ? String(tenantid) : ''}
|
||||
onChange={chooseTenant}
|
||||
placeholder="Select merchant…"
|
||||
hasSearch
|
||||
/>
|
||||
<Selector
|
||||
label="Outlet"
|
||||
options={locationOptions}
|
||||
value={locationid ? String(locationid) : ''}
|
||||
onChange={chooseLocation}
|
||||
placeholder={
|
||||
!tenantid
|
||||
? 'Choose a merchant first…'
|
||||
: locations.isLoading
|
||||
? 'Loading outlets…'
|
||||
: locationOptions.length === 0
|
||||
? 'This merchant has no outlets'
|
||||
: 'Select outlet…'
|
||||
}
|
||||
isDisabled={!tenantid || locationOptions.length === 0}
|
||||
hasSearch={locationOptions.length > 6}
|
||||
/>
|
||||
</div>
|
||||
</VStack>
|
||||
</Card>
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{selected && hasTarget ? (
|
||||
<ImportDrawer
|
||||
product={selected}
|
||||
tenantid={tenantid as number}
|
||||
locationid={locationid as number}
|
||||
isSaving={importMutation.isPending}
|
||||
error={importMutation.error}
|
||||
onClose={() => setSelected(null)}
|
||||
onSubmit={(row) => importMutation.mutate([row])}
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
locationid={undefined}
|
||||
categoryOptions={[]}
|
||||
actionLabel="Add to store"
|
||||
isReadOnly
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,11 +37,20 @@ const EMPTY: FormState = {
|
||||
primarycontact: '',
|
||||
primaryemail: '',
|
||||
locationname: '',
|
||||
categoryid: '1',
|
||||
// No default category. `utils/getappcategories` supplies the real list and
|
||||
// this used to pre-select the first id regardless of what the merchant sells.
|
||||
categoryid: '',
|
||||
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: '',
|
||||
};
|
||||
|
||||
@@ -90,6 +99,8 @@ export function OnboardTenantPage() {
|
||||
form.locationname.trim() !== '' &&
|
||||
form.address.trim() !== '' &&
|
||||
form.city.trim() !== '' &&
|
||||
form.state.trim() !== '' &&
|
||||
form.categoryid !== '' &&
|
||||
form.postcode.trim() !== '';
|
||||
|
||||
function handleSubmit(event: FormEvent) {
|
||||
@@ -101,7 +112,9 @@ export function OnboardTenantPage() {
|
||||
primarycontact: form.primarycontact.trim(),
|
||||
primaryemail: form.primaryemail.trim(),
|
||||
locationname: form.locationname.trim(),
|
||||
categoryid: Number(form.categoryid),
|
||||
// `Number('')` is NaN, which serialises to null and is not what the
|
||||
// backend means by "uncategorised" — 0 is.
|
||||
categoryid: Number(form.categoryid) || 0,
|
||||
address: form.address.trim(),
|
||||
suburb: form.suburb.trim(),
|
||||
city: form.city.trim(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { IndianRupee, PackageCheck, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
|
||||
import { IndianRupee, ShoppingCart, Store, TriangleAlert } from 'lucide-react';
|
||||
import { DataState } from '@/components/DataState';
|
||||
import { Freshness } from '@/components/Freshness';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
@@ -226,13 +226,13 @@ export function StoreDetailPage() {
|
||||
icon={<TriangleAlert size={15} />}
|
||||
fill={totals.totalOrders ? totals.cancelled / totals.totalOrders : 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Catalogue"
|
||||
value={tenant ? 'Linked' : '—'}
|
||||
note="stocked per branch"
|
||||
tone="neutral"
|
||||
icon={<PackageCheck size={15} />}
|
||||
/>
|
||||
{/* No "Catalogue — Linked" tile.
|
||||
|
||||
It read `tenant ? 'Linked' : '—'`, so it said Linked whenever a
|
||||
tenant row came back at all — it described the tenant existing, not
|
||||
the catalogue. `products/getimportedcatalogueproducts` could give a
|
||||
real imported count here; until something calls it, a tile that is
|
||||
always green is worse than no tile. */}
|
||||
</div>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
|
||||
@@ -180,11 +180,15 @@ export function StoresPage() {
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{/* No `isLive` here. Both of this page's reads — `useTenants` and
|
||||
`useTenantsByApproval` — use the `stable` query options: a 5-minute
|
||||
staleTime and no refetchInterval. The pill claimed a freshness the page
|
||||
does not have. The pages that keep it (Console, Sales, Counters, store
|
||||
detail) poll on a real interval. */}
|
||||
<PageHeader
|
||||
title="Stores"
|
||||
count={totals.tenants ? `${totals.tenants} tenants` : undefined}
|
||||
description="Every merchant on the platform, the branches under them, and how each one is performing."
|
||||
isLive
|
||||
actions={
|
||||
<Button
|
||||
label="Onboard tenant"
|
||||
|
||||
Reference in New Issue
Block a user