backend integration started

This commit is contained in:
2026-08-26 18:37:28 +05:30
parent 517a99d577
commit 3545819be8
50 changed files with 3461 additions and 3241 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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(),

View File

@@ -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}>

View File

@@ -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"