store user login
This commit is contained in:
@@ -43,7 +43,15 @@ export function ImportDrawer({
|
||||
|
||||
const [categoryid, setCategoryid] = useState('');
|
||||
const [subcategoryid, setSubcategoryid] = useState('');
|
||||
const [retailprice, setRetailprice] = useState<number | null>(null);
|
||||
/**
|
||||
* 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);
|
||||
@@ -102,6 +110,8 @@ export function ImportDrawer({
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<CatalogueFacts product={product} />
|
||||
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label={<span>Category <span style={{ color: 'var(--color-error)' }}>*</span></span> as any}
|
||||
@@ -179,3 +189,68 @@ export function ImportDrawer({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,7 +201,6 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
note="parsed in the browser, then written row by row"
|
||||
action={
|
||||
<Button
|
||||
label="Download template"
|
||||
|
||||
@@ -137,7 +137,7 @@ export function GlobalCataloguePage() {
|
||||
is asked once, up front, rather than per product. */}
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader title="Stocking" note="which store these products go to" />
|
||||
<SectionHeader title="Stocking" />
|
||||
<div className="form-grid">
|
||||
<Selector
|
||||
label="Tenant"
|
||||
@@ -173,7 +173,6 @@ export function GlobalCataloguePage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Products"
|
||||
note="filters narrow an already-visible list"
|
||||
action={
|
||||
<HStack gap={1} wrap="wrap">
|
||||
<div style={{ width: 190 }}>
|
||||
|
||||
@@ -15,13 +15,7 @@ import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
const BUSINESS_CATEGORIES = [
|
||||
{ value: '1', label: 'Grocery & Daily' },
|
||||
{ value: '2', label: 'Fresh & Produce' },
|
||||
{ value: '3', label: 'Pharmacy' },
|
||||
{ value: '4', label: 'Bakery' },
|
||||
];
|
||||
import { useAppCategories } from '@/queries/hooks';
|
||||
|
||||
interface FormState {
|
||||
tenantname: string;
|
||||
@@ -68,6 +62,12 @@ export function OnboardTenantPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
/**
|
||||
* The category list comes from `app_category`, not from four values typed
|
||||
* into this file. A category added to the master should appear here without
|
||||
* a release.
|
||||
*/
|
||||
const categories = useAppCategories();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof FormState>(key: K) {
|
||||
@@ -167,7 +167,6 @@ export function OnboardTenantPage() {
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Business"
|
||||
note="who the merchant is"
|
||||
action={<Building2 size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
@@ -205,7 +204,11 @@ export function OnboardTenantPage() {
|
||||
/>
|
||||
<Selector
|
||||
label="Business category"
|
||||
options={BUSINESS_CATEGORIES}
|
||||
options={(categories.data ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
}))}
|
||||
isDisabled={categories.isLoading}
|
||||
value={form.categoryid}
|
||||
onChange={set('categoryid')}
|
||||
/>
|
||||
@@ -217,7 +220,6 @@ export function OnboardTenantPage() {
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Head office"
|
||||
note="where the business is registered"
|
||||
action={<MapPin size={17} style={{ color: 'var(--color-slate-400)' }} />}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -259,10 +261,6 @@ export function OnboardTenantPage() {
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Products are added after the tenant exists — both import paths need its tenantid and
|
||||
locationid.
|
||||
</Text>
|
||||
<Button
|
||||
label={mutation.isPending ? 'Provisioning…' : 'Provision tenant'}
|
||||
type="submit"
|
||||
|
||||
@@ -238,7 +238,6 @@ export function StoreDetailPage() {
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Branches"
|
||||
note="orders and revenue are online only — counter sales are per branch"
|
||||
action={<Freshness updatedAt={summary.dataUpdatedAt} isFetching={summary.isFetching} />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
@@ -12,7 +13,7 @@ import { DataState } from '@/components/DataState';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useTenants } from '@/queries/hooks';
|
||||
import { useTenants, useTenantsByApproval } from '@/queries/hooks';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
|
||||
/** A tenant, with its branches folded in. */
|
||||
@@ -34,15 +35,39 @@ interface TenantRow extends Record<string, unknown> {
|
||||
* grouped by tenantid here rather than shown raw — otherwise a tenant with six
|
||||
* branches reads as six tenants.
|
||||
*/
|
||||
type Tab = 'directory' | 'pending';
|
||||
|
||||
/** Rows per page. One more than this is fetched, to know whether there is a next. */
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function StoresPage() {
|
||||
const { data, isLoading, error } = useTenants();
|
||||
const [tab, setTab] = useState<Tab>('directory');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
/**
|
||||
* A page at a time, newest first — `getalltenants` orders by `tenantid DESC`
|
||||
* and has no total, so paging is "ask for one more than we show and see if it
|
||||
* comes back". A platform list read whole is fine at twenty tenants and not
|
||||
* at two thousand.
|
||||
*/
|
||||
const { data, isLoading, error } = useTenants({ pageno: page, pagesize: PAGE_SIZE + 1 });
|
||||
|
||||
/**
|
||||
* The queue of merchants awaiting approval.
|
||||
*
|
||||
* A separate endpoint, not a filter: `approved = 0` rows do not appear in
|
||||
* `getalltenants` at all, so without this they are invisible. Nothing here
|
||||
* can approve one — `approved` is writable only at creation — so this lists
|
||||
* and says so.
|
||||
*/
|
||||
const pending = useTenantsByApproval('pending');
|
||||
|
||||
const rows = useMemo<TenantRow[]>(() => {
|
||||
if (!data) return [];
|
||||
const grouped = new Map<number, TenantRow>();
|
||||
|
||||
for (const tenant of data as TenantInfo[]) {
|
||||
for (const tenant of (data as TenantInfo[]).slice(0, PAGE_SIZE)) {
|
||||
const existing = grouped.get(tenant.tenantid);
|
||||
if (existing) {
|
||||
existing.branches += 1;
|
||||
@@ -196,6 +221,23 @@ export function StoresPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HStack gap={0.5} wrap="wrap">
|
||||
<TabButton
|
||||
label="Directory"
|
||||
isActive={tab === 'directory'}
|
||||
onClick={() => setTab('directory')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Awaiting approval"
|
||||
isActive={tab === 'pending'}
|
||||
badge={pending.data?.length || undefined}
|
||||
onClick={() => setTab('pending')}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{tab === 'pending' ? (
|
||||
<PendingPanel rows={pending.data ?? []} isLoading={pending.isLoading} />
|
||||
) : (
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="Tenant directory"
|
||||
@@ -246,7 +288,200 @@ export function StoresPage() {
|
||||
</div>
|
||||
</DataState>
|
||||
</Card>
|
||||
|
||||
<HStack justify="between" align="center" gap={1} wrap="wrap">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Page {page}
|
||||
{search ? ` · filtered from ${rows.length} on this page` : ''}
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<Button
|
||||
label="Previous"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={page === 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={(data?.length ?? 0) <= PAGE_SIZE}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merchants that have been created but never approved.
|
||||
*
|
||||
* Read-only, and it says why on the panel rather than offering a button that
|
||||
* cannot work: `tenants.approved` is written once, at creation, and there is no
|
||||
* update or approve endpoint anywhere in the backend. Until there is, this is
|
||||
* the only place these rows are visible at all — `getalltenants` cannot see
|
||||
* them.
|
||||
*/
|
||||
function PendingPanel({ rows, isLoading }: { rows: TenantInfo[]; isLoading: boolean }) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack padding={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading the approval queue…
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Nothing waiting
|
||||
</Text>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Every merchant on the platform has been approved.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<Text type="body" size="sm" color="secondary" style={{ lineHeight: 1.6 }}>
|
||||
These merchants exist but are not approved, so they do not appear in the directory.
|
||||
Approving is a database change today — the backend has no endpoint for it.
|
||||
</Text>
|
||||
<Card padding={0} elevation="low">
|
||||
<div className="table-scroll">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Merchant</Th>
|
||||
<Th>Company</Th>
|
||||
<Th>Contact</Th>
|
||||
<Th>City</Th>
|
||||
<Th>Tenant id</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.tenantid}-${row.locationid}`}>
|
||||
<Td isStrong>{row.tenantname}</Td>
|
||||
<Td isMuted>{row.companyname || '—'}</Td>
|
||||
<Td isMuted>{row.primaryemail || row.primarycontact || '—'}</Td>
|
||||
<Td isMuted>{row.city || '—'}</Td>
|
||||
<Td isMuted>{row.tenantid}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
height: 32,
|
||||
padding: '0 12px',
|
||||
borderRadius: 12,
|
||||
border: 0,
|
||||
background: isActive ? 'var(--color-brand-tint)' : 'transparent',
|
||||
color: isActive ? 'var(--color-brand)' : 'var(--color-ink-3)',
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
padding: '0 5px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-warning, #b7860b)',
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-ink-3)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
isMuted,
|
||||
isStrong,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isMuted?: boolean;
|
||||
isStrong?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
borderBottom: '1px solid color-mix(in oklab, var(--color-line) 55%, transparent)',
|
||||
color: isMuted ? 'var(--color-ink-3)' : 'var(--color-ink-1)',
|
||||
fontWeight: isStrong ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user