150 lines
5.6 KiB
TypeScript
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.`;
|
|
}
|