Files
daily_console_web/src/features/import/ImportScope.tsx
2026-09-28 18:08:55 +05:30

150 lines
5.6 KiB
TypeScript

/**
* Who an uploaded sheet is for.
*
* A sheet becomes stock on one shelf, so the upload has to name a tenant and a
* branch before it can do anything — and where those come from depends entirely
* on who is signed in:
*
* - A **Nearle Admin** works across every merchant. Nothing in their session
* says which one, so they are ASKED, and the upload stays blocked until
* they answer. Guessing here would put another merchant's products on a
* shop's shelf.
* - A **Store Admin or Store user** is already scoped by their login. Asking
* them to pick their own tenant would be offering a choice with one legal
* answer, and a chance to get it wrong.
*
* The branch matters as much as the tenant and is easier to forget: stock is
* held per outlet, so a sheet uploaded against the wrong branch loads a shelf
* nobody is standing at.
*
* A CATEGORY IS NOT ASKED FOR, and its absence is deliberate. It used to be the
* third question here, and one answer was applied to every row in the sheet —
* so a shop uploading its whole range had butter, bleach and biryani masala all
* filed under the same aisle, and the app's category filter showed exactly that.
* The category is now worked out per product from the catalogue team's ladder
* (`productCategory.ts`) and resolved to an id at shelving time, which is a
* classification and not a preference: there is nothing here for anyone to get
* right or wrong.
*/
import { useEffect, useMemo } from 'react';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { VStack } from '@astryxdesign/core/VStack';
import { useTenantLocations, useTenants } from '@/queries/hooks';
/** Everything an upload needs before it can be turned into stock. */
export interface ImportTarget {
tenantid: number;
locationid: number;
}
export interface ImportScopeProps {
value: Partial<ImportTarget>;
onChange: (next: Partial<ImportTarget>) => void;
/**
* True for a store login, whose tenant is fixed by the session.
*
* The tenant picker is then not merely disabled but absent: a control that
* cannot be used is still a question, and this one has no question in it.
*/
isTenantFixed?: boolean;
}
export function ImportScope({ value, onChange, isTenantFixed = false }: ImportScopeProps) {
const tenants = useTenants({ pageno: 1, pagesize: 200 });
const locations = useTenantLocations(value.tenantid);
const tenantOptions = useMemo(
() =>
(tenants.data ?? [])
.filter((tenant) => tenant.tenantid)
.map((tenant) => ({
value: String(tenant.tenantid),
label: tenant.tenantname || `Tenant ${tenant.tenantid}`,
})),
[tenants.data],
);
const branchOptions = useMemo(
() =>
(locations.data ?? []).map((branch) => ({
value: String(branch.locationid),
label: branch.locationname || `Branch ${branch.locationid}`,
})),
[locations.data],
);
/**
* A single branch is not a choice, so it is made rather than offered.
*
* Most merchants run one outlet. Leaving it unpicked would block the upload
* behind a dropdown with one entry.
*/
useEffect(() => {
if (!value.tenantid) return;
if (!value.locationid && branchOptions.length === 1) {
onChange({ ...value, locationid: Number(branchOptions[0]!.value) });
}
}, [branchOptions, value, onChange]);
return (
<VStack gap={1.5}>
{!isTenantFixed ? (
<Selector
label="Merchant"
size="sm"
options={tenantOptions}
value={value.tenantid ? String(value.tenantid) : ''}
placeholder={tenants.isLoading ? 'Loading merchants…' : 'Choose a merchant'}
description="Whose catalogue this sheet is loading. Nothing is uploaded until this is set."
onChange={(next) =>
// Changing the merchant clears the branch with it. Keeping it
// would leave another tenant's branch id attached to this one — an
// id that is valid-looking and wrong.
onChange({ tenantid: Number(next) || undefined })
}
/>
) : null}
<Selector
label="Branch"
size="sm"
options={branchOptions}
value={value.locationid ? String(value.locationid) : ''}
placeholder={
!value.tenantid
? 'Choose a merchant first'
: locations.isLoading
? 'Loading branches…'
: 'Choose a branch'
}
description="Where the opening stock lands. Stock is held per branch, so this decides which shelf fills."
isDisabled={!value.tenantid}
onChange={(next) => onChange({ ...value, locationid: Number(next) || undefined })}
/>
{value.tenantid && branchOptions.length === 0 && !locations.isLoading ? (
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
This merchant has no branches, so there is nowhere for stock to land. Create one first.
</Text>
) : null}
</VStack>
);
}
/** True when every field an import needs has been answered. */
export function isTargetComplete(value: Partial<ImportTarget>): value is ImportTarget {
return Boolean(value.tenantid && value.locationid);
}
/** What is still missing, in words, for a blocked-reason line. */
export function describeMissingTarget(value: Partial<ImportTarget>): string | null {
const missing = [
!value.tenantid ? 'a merchant' : null,
!value.locationid ? 'a branch' : null,
].filter(Boolean);
if (missing.length === 0) return null;
return `Choose ${missing.join(', ')} before uploading — a sheet becomes stock on one shelf, and this is which.`;
}