lat and long
This commit is contained in:
397
src/api/ingest.ts
Normal file
397
src/api/ingest.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* The catalogue ingest service — `mcp.nearle.ai.in`, pipeline v3.2.0.
|
||||
*
|
||||
* A workbook goes up, the service parses and enriches it, and the rows land in
|
||||
* the global catalogue's per-brand tables. It replaces the row-by-row create
|
||||
* loop the console used to run in the browser.
|
||||
*
|
||||
* Everything here is written against the contract the owning team supplied, not
|
||||
* against a guess. Where a decision looks arbitrary it usually is not — the
|
||||
* reason is in the comment.
|
||||
*
|
||||
* ── Submit, then poll ────────────────────────────────────────────────────────
|
||||
*
|
||||
* `POST /ingest` answers 202 with a job id and hands off to a background
|
||||
* thread; the outcome arrives from `GET /jobs/{id}`. Two operational details
|
||||
* from the owning team shape `pollJob` below, and both are the kind of thing
|
||||
* that silently produces a wrong screen if ignored:
|
||||
*
|
||||
* - **Jobs live in memory.** A backend restart loses them and polling returns
|
||||
* 404. That is "we no longer know", NOT "it failed" — the rows may well have
|
||||
* been written. Reporting a failure there would send someone re-uploading a
|
||||
* sheet that already landed.
|
||||
* - **`products_built > 0` is not success.** A job whose rows were built but
|
||||
* could not be stored is marked `failed` with the reason in
|
||||
* `result.storage_error`. The counts are populated either way, so reading
|
||||
* them without checking `status` reports an import that never happened.
|
||||
*
|
||||
* ── The credential never reaches this file ───────────────────────────────────
|
||||
*
|
||||
* `X-API-Key`, attached by the Vite proxy from `INGEST_TOKEN` in `.env.local`.
|
||||
* The key carries `require_admin`, which on that service is a superuser — the
|
||||
* same key reaches `/api/catalog/generate`, `/api/system/init` and the ML
|
||||
* training endpoints. A key in the browser bundle is a key published to every
|
||||
* visitor, so it stays server-side and this module never sees one.
|
||||
*
|
||||
* That is why production is not solved here. The console's origin is not in
|
||||
* their `API_CORS_ORIGINS` and should not be added: the owning team's own
|
||||
* recommendation is server-to-server, which means Fiesta relays the call. Until
|
||||
* that exists, this path works in development only.
|
||||
*/
|
||||
|
||||
const INGEST_BASE = import.meta.env['VITE_INGEST_BASE'] ?? '/ingest';
|
||||
|
||||
const ROOT = '/api/admin/store-catalog';
|
||||
|
||||
/**
|
||||
* Client-side limits, mirroring the service's own.
|
||||
*
|
||||
* Checked here so a 12 MB workbook is refused in the browser instead of being
|
||||
* uploaded over a shop's connection to earn a 413. The service remains the
|
||||
* authority; this is politeness, not validation.
|
||||
*/
|
||||
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_ROWS = 2000;
|
||||
|
||||
/** Everything the service parses. `.txt`/`.tab` included because it takes them. */
|
||||
export const ACCEPTED_EXTENSIONS = ['.xlsx', '.xlsm', '.xls', '.csv', '.tsv', '.txt', '.tab'];
|
||||
|
||||
/* ── Response types, from the owning team's real output ───────────────────── */
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'done' | 'failed';
|
||||
|
||||
/** A row that could not be turned into a product at all. */
|
||||
export interface IngestRowError {
|
||||
/** 1-based spreadsheet row. The header is row 1, so this matches Excel. */
|
||||
row: number;
|
||||
product_name: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** A row that was built, then failed the deterministic validation gate. */
|
||||
export interface IngestRejection {
|
||||
product_name: string;
|
||||
size: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface IngestJobResult {
|
||||
rows_total: number;
|
||||
/** Can exceed `rows_total`: "100g, 200g, 500g" in one cell is three products. */
|
||||
products_built: number;
|
||||
inserted: number;
|
||||
/** Existing rows whose blank columns this upload filled in. */
|
||||
backfilled: number;
|
||||
/** Already present and already complete — nothing to do. */
|
||||
skipped_existing: number;
|
||||
rejected: number;
|
||||
brands: string[];
|
||||
/** Capped at 50 by the service; `rejected` carries the true total. */
|
||||
rejections: IngestRejection[];
|
||||
/** Capped at 50 by the service; `error_count` carries the true total. */
|
||||
errors: IngestRowError[];
|
||||
error_count: number;
|
||||
/** Non-fatal corrections that were applied anyway, row-numbered. */
|
||||
warnings: string[];
|
||||
/** Sheet header → the field it was read as. */
|
||||
recognised_columns: Record<string, string>;
|
||||
/** Headers that matched nothing and were silently dropped. */
|
||||
unrecognised_columns: string[];
|
||||
/** Non-null means the rows were built but never stored. */
|
||||
storage_error: string | null;
|
||||
}
|
||||
|
||||
export interface IngestJob {
|
||||
job_id: string;
|
||||
filename: string;
|
||||
status: JobStatus;
|
||||
detail: string | null;
|
||||
stage_index: number;
|
||||
stage_name: string;
|
||||
total_stages: number;
|
||||
rows_done: number;
|
||||
rows_total: number;
|
||||
result: IngestJobResult | null;
|
||||
}
|
||||
|
||||
/** What `/preview` answers — a dry run that writes nothing. */
|
||||
export interface IngestPreview {
|
||||
recognised_columns: Record<string, string>;
|
||||
unrecognised_columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rows_total?: number;
|
||||
}
|
||||
|
||||
export class IngestError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
|
||||
constructor(message: string, status: number, body = '') {
|
||||
super(message);
|
||||
this.name = 'IngestError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a poll found no such job — see the note about in-memory jobs. */
|
||||
export class JobVanishedError extends IngestError {
|
||||
constructor(jobId: string) {
|
||||
super(
|
||||
'The service no longer knows about this job — it restarts with jobs held in memory. The products may well have been written; check the catalogue before uploading again.',
|
||||
404,
|
||||
);
|
||||
this.name = 'JobVanishedError';
|
||||
this.jobId = jobId;
|
||||
}
|
||||
readonly jobId: string;
|
||||
}
|
||||
|
||||
/* ── Requests ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface SubmitOptions {
|
||||
file: File;
|
||||
/**
|
||||
* Default FALSE, and that is not caution — production reports
|
||||
* `"ollama": false`, so the LLM path is not available there. Asking for it
|
||||
* buys nothing and the enrichment that matters (HSN, price band, FSSAI, SKU)
|
||||
* is deterministic lookup rather than generation.
|
||||
*/
|
||||
useLlm?: boolean;
|
||||
/**
|
||||
* Default FALSE. Image fetching is a network round trip per row evaluating up
|
||||
* to 24 candidates, and it is the stage that turns ten seconds into minutes.
|
||||
* Worth turning on deliberately, not by default.
|
||||
*/
|
||||
fetchImages?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function guardFile(file: File): void {
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
throw new IngestError(
|
||||
`That file is ${(file.size / 1024 / 1024).toFixed(1)} MB. The service accepts up to 10 MB.`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
if (file.size === 0) {
|
||||
throw new IngestError('That file is empty.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function send<T>(path: string, file: File, query: URLSearchParams, signal?: AbortSignal) {
|
||||
guardFile(file);
|
||||
|
||||
const form = new FormData();
|
||||
// `file`, confirmed: the handler signature is `file: UploadFile = File(...)`.
|
||||
form.append('file', file, file.name);
|
||||
|
||||
// NOTE: no tenantid/locationid. This endpoint writes the GLOBAL catalogue and
|
||||
// has no concept of an outlet — making a product sellable at a shop is a
|
||||
// separate call to `/api/upload/stores`, keyed on `image_id`. Sending them
|
||||
// here achieved nothing and implied a link that does not exist.
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${INGEST_BASE}${ROOT}${path}?${query}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
// Content-Type is deliberately unset: the browser adds it WITH the
|
||||
// multipart boundary. Setting it by hand omits the boundary and the
|
||||
// server parses nothing.
|
||||
headers: { Accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new IngestError(
|
||||
cause instanceof DOMException && cause.name === 'AbortError'
|
||||
? 'Cancelled.'
|
||||
: 'Could not reach the ingest service.',
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return readResponse<T>(response);
|
||||
}
|
||||
|
||||
async function readResponse<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok) throw describe(response.status, payload, text);
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service's failures, in words that name the fix.
|
||||
*
|
||||
* Each of these has one cause and one remedy, and a generic "request failed"
|
||||
* sends people to look at their spreadsheet for a problem that is in the
|
||||
* deployment.
|
||||
*/
|
||||
function describe(status: number, payload: unknown, text: string): IngestError {
|
||||
const detail =
|
||||
(payload !== null &&
|
||||
typeof payload === 'object' &&
|
||||
typeof (payload as { detail?: unknown }).detail === 'string' &&
|
||||
(payload as { detail: string }).detail) ||
|
||||
undefined;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return new IngestError(
|
||||
detail ??
|
||||
'The ingest service rejected the credential. Set INGEST_TOKEN in .env.local and restart the dev server — and note the key only works once their backend is rebuilt with it, since API_KEYS is baked in at build time.',
|
||||
status,
|
||||
text.slice(0, 2000),
|
||||
);
|
||||
}
|
||||
if (status === 413) {
|
||||
return new IngestError(
|
||||
detail ?? 'Too large for the service — the limits are 10 MB and 2000 rows.',
|
||||
status,
|
||||
text.slice(0, 2000),
|
||||
);
|
||||
}
|
||||
if (status === 400) {
|
||||
return new IngestError(
|
||||
detail ?? 'The service could not read that file — it may be empty or have no data rows.',
|
||||
status,
|
||||
text.slice(0, 2000),
|
||||
);
|
||||
}
|
||||
return new IngestError(detail ?? `The ingest service returned HTTP ${status}.`, status, text.slice(0, 2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* A true dry run. Parses, reports the column mapping and the first rows, and
|
||||
* writes nothing at all.
|
||||
*
|
||||
* Run before every ingest. It is the only way to see `unrecognised_columns`
|
||||
* before the fact, and a header that matched nothing is dropped SILENTLY — a
|
||||
* price column the service never saw looks exactly like a successful import
|
||||
* until someone opens the catalogue.
|
||||
*/
|
||||
export function previewSheet(file: File, signal?: AbortSignal): Promise<IngestPreview> {
|
||||
return send<IngestPreview>('/preview', file, new URLSearchParams(), signal);
|
||||
}
|
||||
|
||||
/** Submits the sheet. Answers 202 with a job to poll — it does not wait. */
|
||||
export function submitIngest(options: SubmitOptions): Promise<IngestJob> {
|
||||
const { file, useLlm = false, fetchImages = false, signal } = options;
|
||||
const query = new URLSearchParams({
|
||||
use_llm: String(useLlm),
|
||||
fetch_images: String(fetchImages),
|
||||
});
|
||||
return send<IngestJob>('/ingest', file, query, signal);
|
||||
}
|
||||
|
||||
/** One poll. Throws `JobVanishedError` on 404 — see the note at the top. */
|
||||
export async function fetchJob(jobId: string, signal?: AbortSignal): Promise<IngestJob> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${INGEST_BASE}${ROOT}/jobs/${encodeURIComponent(jobId)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch {
|
||||
throw new IngestError('Lost contact with the ingest service while waiting.', 0);
|
||||
}
|
||||
if (response.status === 404) throw new JobVanishedError(jobId);
|
||||
return readResponse<IngestJob>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls until the job settles.
|
||||
*
|
||||
* Every second. The service does no rate limiting on this and a person is
|
||||
* watching a progress bar, so a slower cadence buys nothing but a screen that
|
||||
* looks stuck. `onTick` fires on each reading so the caller can render
|
||||
* `stage_name` and `rows_done` as they move.
|
||||
*/
|
||||
export async function pollJob(
|
||||
jobId: string,
|
||||
onTick: (job: IngestJob) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IngestJob> {
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new IngestError('Cancelled.', 0);
|
||||
|
||||
const job = await fetchJob(jobId, signal);
|
||||
onTick(job);
|
||||
if (job.status === 'done' || job.status === 'failed') return job;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Deriving what the service does not return ────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The catalogue's primary key, computed locally.
|
||||
*
|
||||
* The ingest returns counts, not ids — but the key is deterministic, so the
|
||||
* rows can be addressed without being told. That matters for the step after
|
||||
* this one: `/api/upload/stores` joins on exactly this value to put a product
|
||||
* on a shop's shelf.
|
||||
*
|
||||
* image_id = sanitize(brand) + "_" + slugify(name [+ " " + size])
|
||||
*
|
||||
* The size is appended ONLY when its slug is not already inside the name's —
|
||||
* "Good Day Cashew Cookies 100g" with size "100g" must not become
|
||||
* `..._100g_100g`.
|
||||
*
|
||||
* Verified against real output: `britannia_britannia_good_day_cashew_cookies_100g`.
|
||||
*/
|
||||
export function imageId(brand: string, productName: string, size?: string): string {
|
||||
const nameSlug = slugify(productName);
|
||||
const sizeSlug = size ? slugify(size) : '';
|
||||
const tail = sizeSlug && !nameSlug.includes(sizeSlug) ? `${nameSlug}_${sizeSlug}` : nameSlug;
|
||||
return `${sanitize(brand)}_${tail}`;
|
||||
}
|
||||
|
||||
/** lowercase · space, hyphen and & become `_` · drop the rest · collapse runs. */
|
||||
function sanitize(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-&]/g, '_')
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
/** lowercase · any run of non-alphanumerics becomes one `_` · trim. */
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
/* ── Reading a finished job ───────────────────────────────────────────────── */
|
||||
|
||||
/** True when the job ended without the rows reaching the database. */
|
||||
export function isStorageFailure(job: IngestJob): boolean {
|
||||
return job.status === 'failed' || Boolean(job.result?.storage_error);
|
||||
}
|
||||
|
||||
/** One line for the top of the result panel. */
|
||||
export function summarise(job: IngestJob): string {
|
||||
const result = job.result;
|
||||
if (!result) return job.detail ?? 'The service returned no result.';
|
||||
|
||||
if (isStorageFailure(job)) {
|
||||
return result.storage_error
|
||||
? `Built ${result.products_built} products but could not store them: ${result.storage_error}`
|
||||
: (job.detail ?? 'The job failed.');
|
||||
}
|
||||
|
||||
const parts = [`${result.inserted} added`];
|
||||
if (result.backfilled > 0) parts.push(`${result.backfilled} filled in`);
|
||||
if (result.skipped_existing > 0) parts.push(`${result.skipped_existing} already there`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -180,6 +180,16 @@ export interface SheetImportOptions {
|
||||
* batch create, and firing 500 concurrent writes at a single-instance Go
|
||||
* service to save a few seconds is a poor trade against a half-imported tenant.
|
||||
*/
|
||||
/**
|
||||
* NO LONGER WIRED TO ANY SCREEN.
|
||||
*
|
||||
* The Upload sheet panel now hands the workbook to the ingest service
|
||||
* (`api/ingest.ts`) instead of running this loop from the browser. Kept, not
|
||||
* deleted, because the ingest contract is still unconfirmed and this is the
|
||||
* known-working path back if that service turns out not to fit. Delete it once
|
||||
* the ingest has run against real data and been signed off — a second import
|
||||
* path that nobody calls is a thing that rots.
|
||||
*/
|
||||
export async function importSheetProducts(
|
||||
options: SheetImportOptions,
|
||||
): Promise<SheetImportResult> {
|
||||
|
||||
@@ -48,10 +48,24 @@ export function resolveRole(user: Pick<FiestaUser, 'roleid' | 'issuperadmin'>):
|
||||
|
||||
export function toSessionUser(user: FiestaUser): SessionUser {
|
||||
const name = [user.firstname, user.lastname].filter(Boolean).join(' ').trim();
|
||||
|
||||
/**
|
||||
* Trimmed, and that is load-bearing.
|
||||
*
|
||||
* `fullname` is not a column — `GetTenantUserById` builds it as
|
||||
* `concat(a.firstname,' ',a.lastname)` (`userRepository.go:249`). For an
|
||||
* account with neither name filled in, that concat produces a SINGLE SPACE,
|
||||
* not an empty string, and a single space is truthy. So the fallback chain
|
||||
* below stopped here and handed the app a name of " ", which then rendered as
|
||||
* an avatar with no initials in it. Trimming lets it fall through to the
|
||||
* email, which every account has.
|
||||
*/
|
||||
const fullname = (user.fullname ?? '').trim();
|
||||
|
||||
return {
|
||||
userid: user.userid,
|
||||
role: resolveRole(user),
|
||||
name: name || user.fullname || user.authname || user.email,
|
||||
name: name || fullname || user.authname || user.email,
|
||||
email: user.email,
|
||||
roleid: user.roleid,
|
||||
tenantid: user.tenantid,
|
||||
|
||||
@@ -77,6 +77,60 @@ export async function login(email: string, password: string): Promise<SessionUse
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the next step is for this email — before anyone types a password.
|
||||
*
|
||||
* `'setup'` means the account exists and has never had a password; `'password'`
|
||||
* means it has one. Anything else throws with a message worth showing.
|
||||
*
|
||||
* ── Why probe at all ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* A tenant created by `createtenantuser`, and every branch created by
|
||||
* `createtenantlocation`, is spawned with an EMPTY password. Their owner's
|
||||
* first sign-in therefore cannot succeed, and asking them for a password first
|
||||
* asks for something that does not exist — they type a guess, watch it fail,
|
||||
* and only then are told to invent one. The old console avoids that by checking
|
||||
* the email before the password field is ever shown, and it is right to.
|
||||
*
|
||||
* ── How one endpoint answers two questions ───────────────────────────────────
|
||||
*
|
||||
* There is no lookup endpoint. This posts to `applogin` with no password at
|
||||
* all, which `userService.go:64-123` answers in four distinguishable ways:
|
||||
*
|
||||
* 409 + status false → no such account ("Invalid Email")
|
||||
* 403 → account deactivated
|
||||
* 409 + status true → exists, no password set (carries the userid)
|
||||
* 401 + status true → exists, has a password ("Password is required")
|
||||
*
|
||||
* The last one is the whole trick: a password-less attempt against a real
|
||||
* account is refused with a DIFFERENT code than a wrong password, so existence
|
||||
* can be established without guessing at one.
|
||||
*
|
||||
* This does tell an anonymous caller whether an email has an account here. That
|
||||
* is a real disclosure and worth naming — but the login already answers
|
||||
* "Invalid Email" versus "Incorrect password" to any caller who sends a wrong
|
||||
* password, so the probe reveals nothing that was not already available with
|
||||
* one more field filled in.
|
||||
*/
|
||||
export type AccountCheck = { state: 'password' } | { state: 'setup'; userid: number };
|
||||
|
||||
export async function checkAccount(email: string): Promise<AccountCheck> {
|
||||
const envelope = await api.envelope<{ setup?: boolean; userid?: number }>(
|
||||
`${WEB}/users/applogin`,
|
||||
{ method: 'POST', body: { authname: email.trim(), configid: CONFIG_ID } },
|
||||
);
|
||||
|
||||
if (envelope.code === 409 && envelope.details?.setup === true) {
|
||||
return { state: 'setup', userid: envelope.details.userid ?? 0 };
|
||||
}
|
||||
// "Password is required" — the account is real and has one. Exactly what we
|
||||
// wanted to learn, arriving as a refusal.
|
||||
if (envelope.code === 401) {
|
||||
return { state: 'password' };
|
||||
}
|
||||
throw new Error(loginMessage(envelope.code, envelope.message));
|
||||
}
|
||||
|
||||
/** The backend's floor, enforced here too so the refusal is instant. */
|
||||
export const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
|
||||
@@ -675,11 +675,37 @@ const Rule = () => (
|
||||
<div style={{ height: 1, background: 'var(--color-line)', margin: '5px 0' }} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Up to two letters for the avatar. Never a dash.
|
||||
*
|
||||
* First name and last name where the account has them. Where it does not —
|
||||
* plenty of rows on this backend carry neither — the email is used instead,
|
||||
* split on the separators people actually put in addresses, so
|
||||
* `ragul.kumar@shop.in` gives RK and `care@nearle.in` gives C.
|
||||
*
|
||||
* The old version returned an em dash for anything it could not parse, and that
|
||||
* is what every Store Admin and Store user saw: their accounts have no first or
|
||||
* last name, so the avatar was a dash on every page. A dash says nothing and
|
||||
* looks like a bug, which is worse than one letter.
|
||||
*/
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '—';
|
||||
const first = parts[0]?.[0] ?? '';
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === '') return '?';
|
||||
|
||||
// An email is not a name. Take the part before the @ and read the words out
|
||||
// of it — a full address would otherwise give the domain's letter as the
|
||||
// second initial.
|
||||
const source = trimmed.includes('@') ? (trimmed.split('@')[0] ?? trimmed) : trimmed;
|
||||
|
||||
const words = source
|
||||
.split(/[\s._\-+]+/)
|
||||
.map((word) => word.replace(/[^\p{L}\p{N}]/gu, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
if (words.length === 0) return '?';
|
||||
|
||||
const first = words[0]?.[0] ?? '';
|
||||
const last = words.length > 1 ? (words[words.length - 1]?.[0] ?? '') : '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { HOME_ROUTE } from '@/auth/roles';
|
||||
import {
|
||||
checkAccount,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
PasswordSetupRequiredError,
|
||||
setInitialPassword,
|
||||
@@ -43,10 +44,24 @@ export function LoginPage() {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
/**
|
||||
* The userid `applogin` returned for an account with no password, or null.
|
||||
* Which of the three steps is on screen.
|
||||
*
|
||||
* Non-null flips this page into its second state. It is deliberately not a
|
||||
* route: the userid only exists because a sign-in attempt just produced it,
|
||||
* Email first, always. A tenant made by `createtenantuser` and every branch
|
||||
* made by `createtenantlocation` is spawned with an EMPTY password, so their
|
||||
* owner's first sign-in cannot succeed — and a form that asks for the
|
||||
* password up front asks them for something that does not exist yet. They
|
||||
* guess, it fails, and only then are they told to invent one.
|
||||
*
|
||||
* So the email is checked before a password field is ever shown, and the page
|
||||
* goes straight to whichever step that account actually needs. This is what
|
||||
* the old console does, and it is the right shape.
|
||||
*/
|
||||
const [step, setStep] = useState<'email' | 'password' | 'setup'>('email');
|
||||
|
||||
/**
|
||||
* The userid the probe returned for an account with no password.
|
||||
*
|
||||
* Deliberately not a route: it exists only because a check just produced it,
|
||||
* and a `/set-password` URL that could be opened cold would be a way to set
|
||||
* any account's password from nothing.
|
||||
*/
|
||||
@@ -56,6 +71,28 @@ export function LoginPage() {
|
||||
|
||||
if (user) return <Navigate to={HOME_ROUTE[user.role]} replace />;
|
||||
|
||||
/** Step one: which door does this email need? */
|
||||
async function handleEmail(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const check = await checkAccount(email);
|
||||
if (check.state === 'setup') {
|
||||
setSetupUserid(check.userid);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setStep('setup');
|
||||
} else {
|
||||
setStep('password');
|
||||
}
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : 'Could not check that email');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -64,12 +101,14 @@ export function LoginPage() {
|
||||
const session = await signIn(email, password);
|
||||
navigate(HOME_ROUTE[session.role], { replace: true });
|
||||
} catch (cause) {
|
||||
// Not a failure — the first step. The account exists and has no password,
|
||||
// and the backend handed back the userid to set one against.
|
||||
// Still handled, even though the probe should have caught it. Between the
|
||||
// check and the submit an administrator could have cleared the password,
|
||||
// and the account would otherwise dead-end on "Invalid Email".
|
||||
if (cause instanceof PasswordSetupRequiredError) {
|
||||
setSetupUserid(cause.userid);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setStep('setup');
|
||||
} else {
|
||||
setError(cause instanceof Error ? cause.message : 'Sign-in failed');
|
||||
}
|
||||
@@ -78,6 +117,16 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to the email field, from either of the two second steps. */
|
||||
function restart() {
|
||||
setStep('email');
|
||||
setSetupUserid(null);
|
||||
setPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the password, then sign in with it.
|
||||
*
|
||||
@@ -107,7 +156,8 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = email.trim() !== '' && password !== '' && !isBusy;
|
||||
const canSubmit =
|
||||
step === 'email' ? email.trim() !== '' && !isBusy : password !== '' && !isBusy;
|
||||
const canSetup =
|
||||
newPassword.length >= MIN_PASSWORD_LENGTH && confirmPassword !== '' && !isBusy;
|
||||
|
||||
@@ -129,8 +179,9 @@ export function LoginPage() {
|
||||
}}
|
||||
>
|
||||
<BrandPanel />
|
||||
{setupUserid === null ? (
|
||||
{step !== 'setup' ? (
|
||||
<FormPanel
|
||||
step={step}
|
||||
email={email}
|
||||
password={password}
|
||||
isPasswordVisible={isPasswordVisible}
|
||||
@@ -140,7 +191,8 @@ export function LoginPage() {
|
||||
onEmail={setEmail}
|
||||
onPassword={setPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={step === 'email' ? handleEmail : handleSubmit}
|
||||
onBack={restart}
|
||||
/>
|
||||
) : (
|
||||
<SetupPanel
|
||||
@@ -155,10 +207,7 @@ export function LoginPage() {
|
||||
onConfirmPassword={setConfirmPassword}
|
||||
onToggleVisible={() => setIsPasswordVisible((visible) => !visible)}
|
||||
onSubmit={handleSetup}
|
||||
onBack={() => {
|
||||
setSetupUserid(null);
|
||||
setError(null);
|
||||
}}
|
||||
onBack={restart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -297,6 +346,8 @@ function BrandPanel() {
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface FormPanelProps {
|
||||
step: 'email' | 'password';
|
||||
onBack: () => void;
|
||||
email: string;
|
||||
password: string;
|
||||
isPasswordVisible: boolean;
|
||||
@@ -310,6 +361,8 @@ interface FormPanelProps {
|
||||
}
|
||||
|
||||
function FormPanel({
|
||||
step,
|
||||
onBack,
|
||||
email,
|
||||
password,
|
||||
isPasswordVisible,
|
||||
@@ -339,7 +392,9 @@ function FormPanel({
|
||||
Welcome back
|
||||
</h1>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
Sign in to manage tenants, branches, catalogue and counter sales.
|
||||
{step === 'email'
|
||||
? 'Sign in to manage tenants, branches, catalogue and counter sales.'
|
||||
: `Signing in as ${email}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -352,12 +407,19 @@ function FormPanel({
|
||||
onChange={(event) => onEmail(event.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
autoFocus={step === 'email'}
|
||||
required
|
||||
style={inputStyle}
|
||||
readOnly={step === 'password'}
|
||||
style={{
|
||||
...inputStyle,
|
||||
...(step === 'password'
|
||||
? { color: 'var(--color-ink-3)', cursor: 'default' }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{step === 'password' ? (
|
||||
<Field
|
||||
label="Password"
|
||||
htmlFor="password"
|
||||
@@ -380,10 +442,12 @@ function FormPanel({
|
||||
onChange={(event) => onPassword(event.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
required
|
||||
style={{ ...inputStyle, paddingRight: 40 }}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{/* Neither "Keep me signed in" nor "Forgot password?" is here any more.
|
||||
|
||||
@@ -399,11 +463,17 @@ function FormPanel({
|
||||
<SubmitButton
|
||||
canSubmit={canSubmit}
|
||||
isBusy={isBusy}
|
||||
busyLabel="Signing in…"
|
||||
label="Continue"
|
||||
busyLabel={step === 'email' ? 'Checking…' : 'Signing in…'}
|
||||
label={step === 'email' ? 'Continue' : 'Sign in'}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{step === 'password' ? (
|
||||
<button type="button" onClick={onBack} style={backLinkStyle}>
|
||||
Use a different account
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
@@ -574,22 +644,7 @@ function SetupPanel({
|
||||
/>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 3,
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={onBack} style={backLinkStyle}>
|
||||
Use a different account
|
||||
</button>
|
||||
</div>
|
||||
@@ -684,6 +739,19 @@ function SubmitButton({
|
||||
);
|
||||
}
|
||||
|
||||
const backLinkStyle: React.CSSProperties = {
|
||||
alignSelf: 'center',
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
fontSize: 12.5,
|
||||
fontFamily: 'inherit',
|
||||
color: 'var(--color-ink-3)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 3,
|
||||
};
|
||||
|
||||
const eyeButtonStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
@@ -8,7 +8,16 @@ import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import type { SheetProductRow } from '@/api/products';
|
||||
import {
|
||||
isStorageFailure,
|
||||
pollJob,
|
||||
previewSheet,
|
||||
submitIngest,
|
||||
summarise,
|
||||
type IngestJob,
|
||||
type IngestPreview,
|
||||
} from '@/api/ingest';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
@@ -23,10 +32,9 @@ interface PreviewRow extends Record<string, unknown> {
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface SheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
/* No props. The ingest writes the global catalogue and takes no tenant and no
|
||||
outlet — see the note in `GlobalCataloguePage`. They return with the
|
||||
inventory step. */
|
||||
|
||||
/**
|
||||
* The spreadsheet import path.
|
||||
@@ -40,21 +48,33 @@ export interface SheetImportPanelProps {
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps) {
|
||||
export function SheetImportPanel() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | null>(null);
|
||||
/**
|
||||
* Non-null while the ingest service is working.
|
||||
*
|
||||
* Not a percentage. The old importer ran N creates from the browser and could
|
||||
* count them; this is one request to a service that scrapes, calls an LLM and
|
||||
* fetches images before it answers, and it reports nothing along the way. A
|
||||
* bar that invented a position would be lying, so it says what is happening
|
||||
* and how many rows are in flight instead.
|
||||
*/
|
||||
/** The service's own dry run. Authoritative about what it will read. */
|
||||
const [dryRun, setDryRun] = useState<IngestPreview | null>(null);
|
||||
/** The job while it runs, and after it settles. */
|
||||
const [job, setJob] = useState<IngestJob | null>(null);
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
setJob(null);
|
||||
setDryRun(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
@@ -64,21 +84,46 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
/**
|
||||
* Ask the service what it would read, without writing anything.
|
||||
*
|
||||
* `/preview` parses the sheet and reports the column mapping and the first
|
||||
* rows. Worth doing every time: a header the service does not recognise is
|
||||
* dropped SILENTLY, so a price column it never saw looks identical to a
|
||||
* successful import until somebody opens the catalogue.
|
||||
*/
|
||||
async function handlePreview() {
|
||||
if (!file) return;
|
||||
setParseError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
setDryRun(await previewSheet(file));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setIsWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit, then poll until it settles.
|
||||
*
|
||||
* The FILE goes up, not the parsed rows — the service does its own parsing
|
||||
* and enrichment and can only do that from the original document. The local
|
||||
* parse still runs, but only to fill the preview table.
|
||||
*/
|
||||
async function handleImport() {
|
||||
if (!file) return;
|
||||
setParseError(null);
|
||||
setIsWorking(true);
|
||||
try {
|
||||
const submitted = await submitIngest({ file });
|
||||
setJob(submitted);
|
||||
setJob(await pollJob(submitted.job_id, setJob));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setIsWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,46 +190,134 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
/* ── Finished ─────────────────────────────────────────────────────────── */
|
||||
|
||||
if (job && (job.status === 'done' || job.status === 'failed')) {
|
||||
const outcome = job.result;
|
||||
const broken = isStorageFailure(job);
|
||||
|
||||
return (
|
||||
<Card padding={4} variant="transparent">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
{broken ? (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-error, #d64545)' }} />
|
||||
) : (outcome?.error_count ?? 0) + (outcome?.rejected ?? 0) > 0 ? (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
) : (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
{summarise(job)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
{outcome ? (
|
||||
<>
|
||||
<Text type="body" color="secondary">
|
||||
{outcome.rows_total} sheet row{outcome.rows_total === 1 ? '' : 's'} became{' '}
|
||||
{outcome.products_built} product{outcome.products_built === 1 ? '' : 's'}
|
||||
{outcome.products_built > outcome.rows_total
|
||||
? ' — a cell listing several pack sizes becomes one product each.'
|
||||
: '.'}
|
||||
{outcome.brands.length > 0 ? ` Brands touched: ${outcome.brands.join(', ')}.` : ''}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
{/* Headers the service did not recognise.
|
||||
|
||||
High on the panel because they are dropped silently. A price
|
||||
column it never read looks exactly like a clean import. */}
|
||||
{outcome.unrecognised_columns.length > 0 ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Ignored columns
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{outcome.unrecognised_columns.join(', ')} — the service does not recognise these
|
||||
headers, so nothing in them was read.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.errors.length > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{outcome.error_count} row{outcome.error_count === 1 ? '' : 's'} could not be
|
||||
imported
|
||||
</Text>
|
||||
{outcome.errors.slice(0, 10).map((failure) => (
|
||||
<HStack key={`e${failure.row}`} gap={1} align="center" wrap="wrap">
|
||||
<Badge variant="error" label={`Row ${failure.row}`} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.product_name ? `${failure.product_name} — ` : ''}
|
||||
{failure.error}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{outcome.error_count > outcome.errors.length ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{outcome.error_count - outcome.errors.length} more not listed — the service
|
||||
caps this list at 50.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.rejections.length > 0 ? (
|
||||
<VStack gap={1}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{outcome.rejected} built but rejected by validation
|
||||
</Text>
|
||||
{outcome.rejections.slice(0, 10).map((rejection, index) => (
|
||||
<HStack key={`r${index}`} gap={1} align="center" wrap="wrap">
|
||||
<Badge variant="error" label={rejection.size || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{rejection.product_name} — {rejection.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{outcome.warnings.length > 0 ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Corrections applied
|
||||
</Text>
|
||||
{outcome.warnings.slice(0, 10).map((warning, index) => (
|
||||
<Text key={`w${index}`} type="body" size="sm" color="secondary">
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* The catalogue is not the shelf. Said here because the screen
|
||||
asks for a merchant and an outlet, which makes it look as
|
||||
though the upload put something in their shop. It did not. */}
|
||||
{!broken ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-4)', lineHeight: 1.6 }}>
|
||||
These are in the global catalogue. They are not yet on this outlet’s shelf —
|
||||
putting them there with a price and opening stock is a separate step, and it is
|
||||
not wired up yet.
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text type="body" color="secondary">
|
||||
{job.detail ?? 'The service reported no detail.'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<RawResponse payload={job} />
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
label="Send another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setJob(null);
|
||||
setDryRun(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
@@ -215,7 +348,6 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
<SheetDropzone
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
|
||||
/>
|
||||
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||
@@ -286,31 +418,81 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
{/* The service's dry run, once it has been asked for.
|
||||
|
||||
This is the authoritative answer to "what will it actually
|
||||
read", and the ignored-columns line is the reason to run it: an
|
||||
unrecognised header is dropped without a word. */}
|
||||
{dryRun ? (
|
||||
<VStack gap={0.5}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
What the service will read
|
||||
</Text>
|
||||
{Object.entries(dryRun.recognised_columns ?? {}).map(([field, header]) => (
|
||||
<Text key={field} type="body" size="xsm" color="secondary">
|
||||
{String(header)} → {field}
|
||||
</Text>
|
||||
))}
|
||||
{(dryRun.unrecognised_columns ?? []).length > 0 ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
Ignored: {dryRun.unrecognised_columns.join(', ')} — nothing in these columns is
|
||||
read.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{/* Real progress, from the job.
|
||||
|
||||
`stage_index` of `total_stages` and `rows_done` of `rows_total`
|
||||
both come off each poll, so this is a position rather than a
|
||||
decoration. It falls back to indeterminate for the moment
|
||||
between submitting and the first reading. */}
|
||||
{job && job.status !== 'done' && job.status !== 'failed' ? (
|
||||
<VStack gap={0.5}>
|
||||
{job.rows_total > 0 ? (
|
||||
<ProgressBar
|
||||
label={job.stage_name || 'Working'}
|
||||
value={job.rows_done}
|
||||
max={job.rows_total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max} rows`}
|
||||
/>
|
||||
) : (
|
||||
<ProgressBar label={job.stage_name || 'Starting'} isIndeterminate />
|
||||
)}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Stage {job.stage_index + 1} of {job.total_stages}
|
||||
{job.detail ? ` · ${job.detail}` : ''} — keep this tab open.
|
||||
</Text>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1}>
|
||||
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
Sent whole for parsing and enrichment. The table above is only what we could read
|
||||
locally — check with a dry run first.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
<HStack gap={1} align="center">
|
||||
<Button
|
||||
label="Dry run"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
isDisabled={isWorking || !file}
|
||||
onClick={handlePreview}
|
||||
/>
|
||||
<Button
|
||||
label={isWorking ? 'Working…' : 'Send to catalogue'}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isWorking}
|
||||
isDisabled={isWorking || !file}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
@@ -318,3 +500,65 @@ export function SheetImportPanel({ tenantid, locationid }: SheetImportPanelProps
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* What the ingest service actually replied.
|
||||
*
|
||||
* Collapsed, so it is not in the way, and copyable in one click — the point is
|
||||
* to get the real payload out of a browser and in front of someone who can turn
|
||||
* it into types. Scaffolding: it goes when `api/ingest.ts` stops guessing.
|
||||
*/
|
||||
function RawResponse({ payload }: { payload: unknown }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const text = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}, [payload]);
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<Button
|
||||
label={isOpen ? 'Hide the raw response' : 'Show the raw response'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<Button
|
||||
label="Copy"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigator.clipboard?.writeText(text)}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{isOpen ? (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'var(--color-surface-subtle)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: 'var(--color-ink-2)',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,30 +124,33 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
const categoryid = toNumber(picked.categoryid) ?? 0;
|
||||
const subcategoryid = toNumber(picked.subcategoryid) ?? 0;
|
||||
|
||||
// Only the product name is required, and that is the ingest service's rule
|
||||
// rather than ours: everything else is optional and enriched when blank,
|
||||
// and even the brand is inferred from the name.
|
||||
//
|
||||
// This validation used to demand a SKU, a price, a cost and a numeric
|
||||
// category id, because the console itself was creating the products and
|
||||
// needed all four. It no longer creates anything — the service does — so
|
||||
// enforcing them here would reject sheets the service handles perfectly
|
||||
// well. A missing price is now a warning, not a refusal.
|
||||
let isValid = true;
|
||||
|
||||
if (!productname) {
|
||||
issues.push({ line, field: 'productname', message: 'Product name is missing' });
|
||||
isValid = false;
|
||||
}
|
||||
if (!productsku) {
|
||||
// Without a SKU the created product cannot be found again, and the
|
||||
// create endpoint does not return its new id — so the row would import
|
||||
// and then be impossible to link to the store.
|
||||
issues.push({ line, field: 'productsku', message: 'SKU is missing — the row cannot be linked to a store without one' });
|
||||
isValid = false;
|
||||
if (productname && !String(picked.productbrand ?? '').trim() && productname.split(/\s+/).length < 2) {
|
||||
// The service infers the brand from the name ("Britannia 50-50" →
|
||||
// Britannia) and rejects the row when it cannot. A single bare word is
|
||||
// the case where that is most likely to fail.
|
||||
issues.push({
|
||||
line,
|
||||
field: 'productbrand',
|
||||
message: 'No brand column and the name is one word — the service may not be able to infer a brand',
|
||||
});
|
||||
}
|
||||
if (retailprice === null || retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Retail price is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (productcost === null) {
|
||||
issues.push({ line, field: 'productcost', message: 'Cost is missing or not a number' });
|
||||
isValid = false;
|
||||
}
|
||||
if (categoryid <= 0) {
|
||||
issues.push({ line, field: 'categoryid', message: 'Category id is missing — the tenant has no mapping from a category name' });
|
||||
isValid = false;
|
||||
if (retailprice !== null && retailprice <= 0) {
|
||||
issues.push({ line, field: 'retailprice', message: 'Price is not a positive number' });
|
||||
}
|
||||
|
||||
if (!isValid) return;
|
||||
@@ -168,53 +171,72 @@ export async function parseProductSheet(file: File): Promise<ParsedSheet> {
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate SKUs inside one file are the user's own error and will produce
|
||||
// duplicate products, since nothing on the server dedupes.
|
||||
const seen = new Set<string>();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (seen.has(row.productsku)) {
|
||||
issues.push({
|
||||
line: index + 2,
|
||||
field: 'productsku',
|
||||
message: `SKU ${row.productsku} appears more than once in this file`,
|
||||
});
|
||||
}
|
||||
seen.add(row.productsku);
|
||||
}
|
||||
// The duplicate-SKU check that used to be here is gone.
|
||||
//
|
||||
// It existed because nothing on the old server deduped, so two rows with one
|
||||
// SKU made two products. The ingest service upserts on `image_id` — within
|
||||
// the batch as well as against the table — so a repeated pack now collapses,
|
||||
// last one winning. Flagging it as an error would be warning about behaviour
|
||||
// that is now correct.
|
||||
|
||||
return { rows, issues, unmappedColumns, totalRows: raw.length };
|
||||
}
|
||||
|
||||
/** The template handed to a tenant, so the columns arrive named correctly. */
|
||||
/**
|
||||
* The template handed to a merchant.
|
||||
*
|
||||
* The header names are chosen to survive the INGEST SERVICE's column matcher,
|
||||
* not ours. It matches case-insensitively on keywords, and the old template
|
||||
* collided with it in three places — one of them silently destructive:
|
||||
*
|
||||
* `quantity` → their `size_variants` keyword list. An opening stock of
|
||||
* 40 would have been written into the global catalogue as a
|
||||
* pack size of "40", for every merchant to see.
|
||||
* `productcost` → "cost" is in their price list, so it competed with
|
||||
* `retailprice` for the same field.
|
||||
* `subcategoryid` → contains "category", competing with `categoryid`.
|
||||
*
|
||||
* And `taxpercent` and `unitvalue` matched nothing at all, so they were dropped
|
||||
* without a word — a header that matches nothing is silently ignored, which
|
||||
* looks exactly like a successful import until somebody opens the catalogue.
|
||||
*
|
||||
* So the columns are named in their vocabulary. `Opening Stock` no longer
|
||||
* contains "quantity"; `Pack Size` says what their `size_variants` actually
|
||||
* wants; the price columns are unambiguous.
|
||||
*/
|
||||
export async function downloadTemplate(): Promise<void> {
|
||||
const XLSX = await loadXlsx();
|
||||
const headers = [
|
||||
'productname',
|
||||
'productsku',
|
||||
'categoryid',
|
||||
'subcategoryid',
|
||||
'retailprice',
|
||||
'productcost',
|
||||
'taxpercent',
|
||||
'quantity',
|
||||
'productunit',
|
||||
'unitvalue',
|
||||
'productbrand',
|
||||
'productdesc',
|
||||
'Product Name',
|
||||
'Brand',
|
||||
'Pack Size',
|
||||
'Category',
|
||||
'MRP',
|
||||
'Product Description',
|
||||
'HSN Code',
|
||||
'Barcode (EAN)',
|
||||
'SKU',
|
||||
'Image URL',
|
||||
'Opening Stock',
|
||||
];
|
||||
const example = {
|
||||
productname: 'Aachi Chilli Powder 100g',
|
||||
productsku: 'AACHI-CHILLI-100',
|
||||
categoryid: 1,
|
||||
subcategoryid: 0,
|
||||
retailprice: 35,
|
||||
productcost: 28,
|
||||
taxpercent: 5,
|
||||
quantity: 24,
|
||||
productunit: 'g',
|
||||
unitvalue: '100',
|
||||
productbrand: 'Aachi',
|
||||
productdesc: '',
|
||||
'Product Name': 'Britannia Good Day Cashew Cookies',
|
||||
Brand: 'Britannia',
|
||||
// Their pack-size explosion is real and useful: three sizes in one cell
|
||||
// become three catalogue products.
|
||||
'Pack Size': '100g, 200g',
|
||||
Category: 'Biscuits',
|
||||
MRP: 50,
|
||||
'Product Description': 'Crunchy cashew cookies',
|
||||
'HSN Code': '1905',
|
||||
'Barcode (EAN)': '8901063142107',
|
||||
SKU: '',
|
||||
// Supplying this skips their image-search stage entirely for the row, which
|
||||
// is both faster and more reliable than letting it guess.
|
||||
'Image URL': '',
|
||||
// Read by nobody upstream — the ingest writes the catalogue, not stock.
|
||||
// It is here so one sheet can also drive the outlet inventory step.
|
||||
'Opening Stock': 24,
|
||||
};
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet([example], { header: headers });
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { useState } from 'react';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { PackageSearch, Upload } from 'lucide-react';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { useTenantLocations, useTenants } from '@/queries/hooks';
|
||||
import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
|
||||
/**
|
||||
@@ -25,79 +19,18 @@ import { SheetImportPanel } from '../import/SheetImportPanel';
|
||||
* 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.
|
||||
* - **Upload sheet** is writing, and it writes the GLOBAL catalogue — not one
|
||||
* merchant's shelf. The ingest service parses, enriches and stores rows in
|
||||
* the per-brand tables every merchant reads from, and it takes no tenant and
|
||||
* no outlet. This mode therefore asks for nothing but a file.
|
||||
*
|
||||
* 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 tenantid = Number(params.get('tenantid') ?? 0) || undefined;
|
||||
const locationid = Number(params.get('locationid') ?? 0) || undefined;
|
||||
|
||||
const [mode, setMode] = useState<'catalogue' | 'sheet'>('catalogue');
|
||||
|
||||
const { data: tenants } = useTenants();
|
||||
/**
|
||||
* 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, string>();
|
||||
for (const tenant of tenants as TenantInfo[]) {
|
||||
if (!seen.has(tenant.tenantid)) seen.set(tenant.tenantid, tenant.tenantname);
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ value: String(id), label: name }));
|
||||
}, [tenants]);
|
||||
|
||||
const locationOptions = useMemo(
|
||||
() =>
|
||||
(locations.data ?? []).map((outlet) => ({
|
||||
value: String(outlet.locationid),
|
||||
label: outlet.locationname || `Outlet ${outlet.locationid}`,
|
||||
})),
|
||||
[locations.data],
|
||||
);
|
||||
|
||||
function chooseTenant(value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('tenantid', value);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
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
|
||||
@@ -117,46 +50,19 @@ export function GlobalCataloguePage() {
|
||||
/>
|
||||
|
||||
{mode === 'sheet' ? (
|
||||
<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} variant="transparent">
|
||||
<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>
|
||||
/* The merchant and outlet pickers used to sit above this panel, and
|
||||
they described a flow that no longer exists.
|
||||
|
||||
The ingest endpoint writes the GLOBAL catalogue. It has no concept of
|
||||
a tenant or an outlet — putting a product on one shop's shelf with a
|
||||
price and opening stock is a separate call (`/api/upload/stores`,
|
||||
joined on `image_id`) that is not wired up yet. Two selectors saying
|
||||
the upload was "written against one merchant and one outlet" would
|
||||
have had someone pick a shop, upload, and then go looking for stock
|
||||
that was never going to arrive.
|
||||
|
||||
They come back with the inventory step, and mean something then. */
|
||||
<SheetImportPanel />
|
||||
) : (
|
||||
<CatalogueBrowser
|
||||
tenantid={undefined}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Drawer } from './Drawer';
|
||||
import { SheetImportPanel } from '@/features/nearle-admin/import/SheetImportPanel';
|
||||
import { TenantSheetImportPanel } from './TenantSheetImportPanel';
|
||||
|
||||
/**
|
||||
* Upload a product spreadsheet, from the Products tab.
|
||||
@@ -30,7 +30,7 @@ export function SheetUploadDrawer({
|
||||
width={560}
|
||||
onClose={onClose}
|
||||
>
|
||||
<SheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
<TenantSheetImportPanel tenantid={tenantid} locationid={locationid} />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
338
src/features/store-admin/TenantSheetImportPanel.tsx
Normal file
338
src/features/store-admin/TenantSheetImportPanel.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState } from 'react';
|
||||
/**
|
||||
* A merchant's OWN product list — not the global catalogue.
|
||||
*
|
||||
* This is the browser-side importer that used to serve both consoles. Nearle
|
||||
* Admin's Upload sheet now hands the file to the ingest service instead, which
|
||||
* writes the per-brand tables every merchant reads from. That is a
|
||||
* platform-operator action, and it must not be what happens when a shopkeeper
|
||||
* uploads their own price list: one merchant's spreadsheet would land in front
|
||||
* of all of them.
|
||||
*
|
||||
* So this path stays for the Store Admin. It creates products under that
|
||||
* tenant, lists them at that outlet and gives them opening stock — scoped to
|
||||
* the shop, exactly as before.
|
||||
*/
|
||||
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 { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet } from 'lucide-react';
|
||||
import { importSheetProducts, type SheetImportResult, type SheetProductRow } from '@/api/products';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { SheetDropzone } from '@/components/SheetDropzone';
|
||||
import {
|
||||
downloadTemplate,
|
||||
parseProductSheet,
|
||||
type ParsedSheet,
|
||||
} from '@/features/nearle-admin/import/parseProductSheet';
|
||||
|
||||
interface PreviewRow extends Record<string, unknown> {
|
||||
productname: string;
|
||||
productsku: string;
|
||||
categoryid: number;
|
||||
retailprice: number;
|
||||
productcost: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface TenantSheetImportPanelProps {
|
||||
tenantid: number | undefined;
|
||||
locationid: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet import path.
|
||||
*
|
||||
* Staged rather than one-shot — upload, parse, show what is wrong, then commit —
|
||||
* because the underlying calls are N creates with no transaction and no batch,
|
||||
* so a row that fails on the server fails alone and has to be recoverable.
|
||||
*
|
||||
* It is also not idempotent, and cannot be made so from the client: nothing on
|
||||
* the server dedupes on SKU. Re-uploading the same file creates the products
|
||||
* twice, so that is said out loud before the button rather than discovered
|
||||
* afterwards.
|
||||
*/
|
||||
export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImportPanelProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSheet | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [result, setResult] = useState<SheetImportResult | null>(null);
|
||||
|
||||
const hasTarget = Boolean(tenantid && locationid);
|
||||
|
||||
async function handleFile(next: File | File[] | null) {
|
||||
const chosen = Array.isArray(next) ? (next[0] ?? null) : next;
|
||||
setFile(chosen);
|
||||
setParsed(null);
|
||||
setParseError(null);
|
||||
setResult(null);
|
||||
if (!chosen) return;
|
||||
|
||||
try {
|
||||
setParsed(await parseProductSheet(chosen));
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsed || !tenantid || !locationid) return;
|
||||
setProgress({ done: 0, total: parsed.rows.length });
|
||||
try {
|
||||
const outcome = await importSheetProducts({
|
||||
tenantid,
|
||||
locationid,
|
||||
rows: parsed.rows,
|
||||
onProgress: (done, total) => setProgress({ done, total }),
|
||||
});
|
||||
setResult(outcome);
|
||||
} catch (cause) {
|
||||
setParseError(errorMessage(cause));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const previewColumns: TableColumn<PreviewRow>[] = [
|
||||
{
|
||||
key: 'productname',
|
||||
header: 'Product',
|
||||
width: { type: 'proportional', value: 3 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.productname}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{row.productsku}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categoryid',
|
||||
header: 'Category',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.categoryid}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'retailprice',
|
||||
header: 'Retail',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
₹{row.retailprice}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'productcost',
|
||||
header: 'Cost',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
||||
₹{row.productcost}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Opening stock',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 130 },
|
||||
renderCell: (row) => <Text type="body" size="sm" hasTabularNumbers>{row.quantity}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const preview: PreviewRow[] = (parsed?.rows ?? []).slice(0, 25).map((row: SheetProductRow) => ({
|
||||
productname: row.productname,
|
||||
productsku: row.productsku,
|
||||
categoryid: row.categoryid,
|
||||
retailprice: row.retailprice,
|
||||
productcost: row.productcost,
|
||||
quantity: row.quantity,
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
const isClean = result.failures.length === 0;
|
||||
return (
|
||||
<Card padding={4} variant="transparent">
|
||||
<VStack gap={3}>
|
||||
<HStack align="center" gap={1.5}>
|
||||
{isClean ? (
|
||||
<CheckCircle2 size={22} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
) : (
|
||||
<AlertTriangle size={22} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
)}
|
||||
<Text type="large" weight="semibold">
|
||||
{result.created} product{result.created === 1 ? '' : 's'} imported
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Text type="body" color="secondary">
|
||||
{result.linked} linked to the store, {result.stocked} given opening stock.
|
||||
{isClean ? '' : ` ${result.failures.length} row(s) did not make it.`}
|
||||
</Text>
|
||||
|
||||
{!isClean ? (
|
||||
<VStack gap={1}>
|
||||
{result.failures.slice(0, 10).map((failure, index) => (
|
||||
<HStack key={`${failure.row.productsku}-${index}`} gap={1} align="center">
|
||||
<Badge variant="error" label={failure.row.productsku || '—'} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{failure.reason}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
label="Import another file"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
setFile(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="Upload the tenant's product list"
|
||||
action={
|
||||
<Button
|
||||
label="Download template"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download size={14} />}
|
||||
onClick={() => void downloadTemplate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SheetDropzone
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
blockedReason={hasTarget ? undefined : 'Pick a merchant and outlet first'}
|
||||
/>
|
||||
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
||||
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
||||
the file.
|
||||
</Text>
|
||||
|
||||
{parseError ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{parseError}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
{parsed ? (
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={2} padding={3}>
|
||||
<SectionHeader
|
||||
title="What is in the file"
|
||||
note={`${parsed.totalRows} rows read · ${parsed.rows.length} ready · ${parsed.issues.length} to fix`}
|
||||
/>
|
||||
|
||||
{parsed.unmappedColumns.length > 0 ? (
|
||||
<HStack gap={1} wrap="wrap" align="center">
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Ignored columns:
|
||||
</Text>
|
||||
{parsed.unmappedColumns.map((column) => (
|
||||
<Badge key={column} variant="neutral" label={column} />
|
||||
))}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{parsed.issues.length > 0 ? (
|
||||
<VStack
|
||||
gap={0.5}
|
||||
padding={2}
|
||||
style={{ background: 'var(--color-warning-muted, #fdf6e3)', borderRadius: 12 }}
|
||||
>
|
||||
<HStack align="center" gap={1}>
|
||||
<AlertTriangle size={15} style={{ color: 'var(--color-warning, #b7860b)' }} />
|
||||
<Text type="label" size="sm" style={{ color: 'var(--color-warning, #b7860b)' }}>
|
||||
{parsed.issues.length} row{parsed.issues.length === 1 ? '' : 's'} will be skipped
|
||||
</Text>
|
||||
</HStack>
|
||||
{parsed.issues.slice(0, 8).map((issue, index) => (
|
||||
<Text key={index} type="body" size="xsm" color="secondary">
|
||||
Row {issue.line} · {issue.field}: {issue.message}
|
||||
</Text>
|
||||
))}
|
||||
{parsed.issues.length > 8 ? (
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
…and {parsed.issues.length - 8} more.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{preview.length > 0 ? (
|
||||
<Table<PreviewRow>
|
||||
data={preview}
|
||||
columns={previewColumns}
|
||||
idKey="productsku"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<ProgressBar
|
||||
label="Importing"
|
||||
value={progress.done}
|
||||
max={progress.total}
|
||||
hasValueLabel
|
||||
formatValueLabel={(value, max) => `${value} of ${max}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<HStack align="center" gap={1}>
|
||||
<FileSpreadsheet size={15} style={{ color: 'var(--color-slate-400)' }} />
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing dedupes on SKU — importing this file twice creates every product twice.
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button
|
||||
label={progress ? 'Importing…' : `Import ${parsed.rows.length} products`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={Boolean(progress)}
|
||||
isDisabled={!hasTarget || parsed.rows.length === 0}
|
||||
onClick={handleImport}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Table, type TableColumn } from '@astryxdesign/core/Table';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, EyeOff, Pencil } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
EyeOff,
|
||||
Monitor,
|
||||
Pencil,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useBranchScope } from '../BranchScope';
|
||||
import { DateRangePicker, presetRange, type RangePreset } from '../DateRangePicker';
|
||||
import { branchLabel } from '../format';
|
||||
import { useCounterLabels, type CounterLabels } from '../counterLabels';
|
||||
import { clockTime, spokenAge, UNASSIGNED } from '../posStatus';
|
||||
import { BUCKET_COLOR, BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { BUCKET_LABEL, rupees, type Bucket } from '../terminalProblems';
|
||||
import { useTerminalBoard, type BoardCard, type HealthyCounter } from '../useTerminalBoard';
|
||||
|
||||
/**
|
||||
@@ -43,20 +56,22 @@ export function TerminalsPage() {
|
||||
const board = useTerminalBoard({ branches: scoped, range, isHidden: labels.isHidden });
|
||||
|
||||
const needs = board.now.length + board.look.length;
|
||||
/**
|
||||
* The header line, now that the tiles carry the counts.
|
||||
*
|
||||
* It used to lead with "2 counters need attention. 41 sales haven't reached
|
||||
* us yet" — which the KPI row above now states in larger type, so keeping it
|
||||
* here printed the same two numbers twice, ten pixels apart. What is left is
|
||||
* the one thing no tile can say: why every counter is called T4A9.
|
||||
*/
|
||||
const summary = useMemo(() => {
|
||||
if (board.isLoading && board.total === 0) return 'Checking your counters…';
|
||||
if (board.total === 0) return undefined;
|
||||
if (needs === 0) {
|
||||
return `All ${board.total} counter${board.total === 1 ? '' : 's'} are fine — everything they’ve sold has reached us. Each one is listed by the code printed on your receipts, until you rename it.`;
|
||||
return 'Everything they’ve sold has reached us. Each counter is listed by the code printed on your receipts, until you rename it.';
|
||||
}
|
||||
const stuck = board.strandedBills;
|
||||
const n = board.now.length > 0 ? board.now.length : needs;
|
||||
const head = `${n} counter${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention.`;
|
||||
const tail = ' Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
return stuck > 0
|
||||
? `${head} ${stuck} sale${stuck === 1 ? '' : 's'} haven’t reached us yet.${tail}`
|
||||
: `${head}${tail}`;
|
||||
}, [board, needs]);
|
||||
return 'Counters are listed by the code printed on your receipts, until you rename them.';
|
||||
}, [board.isLoading, board.total, needs]);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
@@ -77,11 +92,45 @@ export function TerminalsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The console's opening move on every operations page — Console, Sales,
|
||||
Inventory, Reports all lead with this same grid. This page used to
|
||||
open with a sentence instead, which is most of why it read as a
|
||||
bolt-on rather than part of the product.
|
||||
|
||||
It also puts the stake where the eye lands. The sales waiting on an
|
||||
unreachable till were previously a clause in a paragraph, and they are
|
||||
the reason anybody opens this page. */}
|
||||
{board.total > 0 ? (
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Not reached"
|
||||
value={String(board.strandedBills)}
|
||||
note={board.strandedBills > 0 ? 'sales we can’t see' : 'everything is in'}
|
||||
tone={board.strandedBills > 0 ? 'error' : 'neutral'}
|
||||
icon={<Send size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Need someone"
|
||||
value={String(board.needCounters)}
|
||||
note={`of ${board.total}`}
|
||||
tone={board.needCounters > 0 ? 'warning' : 'neutral'}
|
||||
icon={<AlertTriangle size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Selling fine"
|
||||
value={String(board.fine.length)}
|
||||
note={`${rupees(board.takenAmount)} taken`}
|
||||
tone="accent"
|
||||
icon={<Monitor size={15} />}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* A failed read is not an answer. Kept visually distinct from "this shop
|
||||
has no counters", because only one of the two means nothing is wrong. */}
|
||||
{board.failed.length > 0 ? (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack gap={1.5} align="start" padding={2}>
|
||||
<Card padding={0} variant="transparent">
|
||||
<HStack gap={1.5} align="start" style={{ ...panelStyle, padding: 16 }}>
|
||||
<span style={{ color: 'var(--color-error, #d64545)', flex: 'none', marginTop: 2 }}>
|
||||
<AlertTriangle size={16} />
|
||||
</span>
|
||||
@@ -122,9 +171,7 @@ export function TerminalsPage() {
|
||||
isOpen={showFine}
|
||||
onToggle={() => setShowFine((open) => !open)}
|
||||
>
|
||||
{board.fine.map((counter) => (
|
||||
<FineRow key={counter.key} counter={counter} labels={labels} />
|
||||
))}
|
||||
<FineTable counters={board.fine} labels={labels} />
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
@@ -158,6 +205,20 @@ export function TerminalsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's one surface.
|
||||
*
|
||||
* A hairline and a radius, no fill. Every block here used to be a white
|
||||
* `elevation="low"` card, which on a tinted page reads as a stack of raised
|
||||
* slabs — and the console has been moving away from that everywhere else.
|
||||
* Defined once so the edges cannot drift apart the next time one of these is
|
||||
* edited.
|
||||
*/
|
||||
const panelStyle: React.CSSProperties = {
|
||||
borderRadius: 14,
|
||||
border: '1px solid var(--color-line)',
|
||||
};
|
||||
|
||||
/* ── Groups ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Group({
|
||||
@@ -177,54 +238,29 @@ function Group({
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
<HStack align="center" gap={1}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flex: 'none',
|
||||
background: BUCKET_COLOR[bucket],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
type="label"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
>
|
||||
{BUCKET_LABEL[bucket]}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{count}
|
||||
</Text>
|
||||
{isCollapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isOpen ? 'Hide' : 'Show'}
|
||||
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</HStack>
|
||||
{/* `SectionHeader`, like every other section in the console. This used to
|
||||
be a hand-built row — an 8px coloured dot, an uppercase micro-label and
|
||||
a bare number — which is a heading style that exists nowhere else in
|
||||
the product. The colour went with it: severity is already carried by
|
||||
the wording and by the order the groups appear in, and saying it a
|
||||
third time in red was the page shouting. */}
|
||||
<SectionHeader
|
||||
title={BUCKET_LABEL[bucket]}
|
||||
note={String(count)}
|
||||
{...(isCollapsible
|
||||
? {
|
||||
action: (
|
||||
<Button
|
||||
label={isOpen ? 'Hide' : 'Show'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={isOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
onClick={onToggle ?? (() => {})}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
{!isCollapsible || isOpen ? <VStack gap={1.5}>{children}</VStack> : null}
|
||||
</VStack>
|
||||
);
|
||||
@@ -243,13 +279,33 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const { problem } = card;
|
||||
const rail = BUCKET_COLOR[problem.bucket];
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<div style={{ display: 'flex', minWidth: 0 }}>
|
||||
<span aria-hidden style={{ width: 3, flex: 'none', background: rail }} />
|
||||
<VStack gap={1} padding={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Card padding={0} variant="transparent">
|
||||
<div
|
||||
style={{ ...panelStyle, display: 'flex', gap: 14, minWidth: 0, padding: 16 }}
|
||||
>
|
||||
{/* The tinted icon tile `KpiCard` uses, in place of the 3px coloured
|
||||
rail this card used to carry. The rail existed nowhere else in the
|
||||
console; the tile is the shape the product already uses to mark
|
||||
what a block is about. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 34,
|
||||
height: 34,
|
||||
flex: 'none',
|
||||
borderRadius: 10,
|
||||
background: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
|
||||
color: 'var(--color-brand)',
|
||||
}}
|
||||
>
|
||||
{problem.bucket === 'now' ? <AlertTriangle size={17} /> : <Monitor size={17} />}
|
||||
</span>
|
||||
|
||||
<VStack gap={1} style={{ minWidth: 0, flex: 1 }}>
|
||||
<CounterName card={card} labels={labels} isRenaming={isRenaming} setIsRenaming={setIsRenaming} />
|
||||
|
||||
<Text type="label" size="lg" weight="semibold">
|
||||
@@ -290,15 +346,25 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels
|
||||
) : null}
|
||||
|
||||
<HStack gap={1} align="center" wrap="wrap" style={{ paddingTop: 2 }}>
|
||||
<SmallButton
|
||||
<Button
|
||||
label={showDetails ? 'Hide details' : 'Details'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDetails((open) => !open)}
|
||||
/>
|
||||
<SmallButton label="Rename" icon={<Pencil size={11} />} onClick={() => setIsRenaming(true)} />
|
||||
<Button
|
||||
label="Rename"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Pencil size={12} />}
|
||||
onClick={() => setIsRenaming(true)}
|
||||
/>
|
||||
{problem.code === 'never-used' || problem.code === 'no-status' ? (
|
||||
<SmallButton
|
||||
<Button
|
||||
label="Hide this counter"
|
||||
icon={<EyeOff size={11} />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<EyeOff size={12} />}
|
||||
onClick={() => labels.hide(card.terminalId)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -345,15 +411,22 @@ function CounterName({
|
||||
placeholder="Counter 2, by the door…"
|
||||
/>
|
||||
</div>
|
||||
<SmallButton
|
||||
<Button
|
||||
label="Save"
|
||||
icon={<Check size={11} />}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Check size={12} />}
|
||||
onClick={() => {
|
||||
labels.rename(card.terminalId, draft);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<SmallButton label="Cancel" onClick={() => setIsRenaming(false)} />
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsRenaming(false)}
|
||||
/>
|
||||
<Text type="body" size="xsm" style={{ color: 'var(--color-ink-4)' }}>
|
||||
{card.terminalId} is the code printed on your receipts. Saved on this browser only.
|
||||
</Text>
|
||||
@@ -454,31 +527,164 @@ function Details({ card }: { card: BoardCard }) {
|
||||
|
||||
/* ── The fine list ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** A counter with nothing wrong gets one line. Anything more is noise. */
|
||||
function FineRow({ counter, labels }: { counter: HealthyCounter; labels: CounterLabels }) {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<HStack justify="between" align="center" gap={2} padding={2} wrap="wrap">
|
||||
<CounterName
|
||||
card={counter}
|
||||
labels={labels}
|
||||
isRenaming={isRenaming}
|
||||
setIsRenaming={setIsRenaming}
|
||||
/>
|
||||
{!isRenaming ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{counter.periodBills} sales · {rupees(counter.periodAmount)}
|
||||
{counter.status.lastBillAt ? ` · last sale ${clockTime(counter.status.lastBillAt)}` : ''}
|
||||
/**
|
||||
* Counters with nothing wrong.
|
||||
*
|
||||
* A Table, like every other list in the console — Stores, Store detail, Users,
|
||||
* Sales. This used to be a stack of hand-built rows, each one a bordered card
|
||||
* carrying a name and a run-on line of `84 sales · ₹52,100 · last sale 14:22`.
|
||||
* Four of those read as four more problems; in a table the same four read as
|
||||
* what they are, which is a quiet list of shops doing their job.
|
||||
*
|
||||
* Renaming moves into the row's own control rather than being inline, because
|
||||
* a text field cannot live inside a table cell without breaking the column.
|
||||
*/
|
||||
interface FineRowData extends Record<string, unknown> {
|
||||
key: string;
|
||||
name: string;
|
||||
code: string;
|
||||
branch: string;
|
||||
bills: number;
|
||||
amount: number;
|
||||
lastSale: string;
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
function FineTable({
|
||||
counters,
|
||||
labels,
|
||||
}: {
|
||||
counters: HealthyCounter[];
|
||||
labels: CounterLabels;
|
||||
}) {
|
||||
const [renaming, setRenaming] = useState<HealthyCounter | null>(null);
|
||||
|
||||
const rows: FineRowData[] = counters.map((counter) => ({
|
||||
key: counter.key,
|
||||
name: labels.nameFor(counter.terminalId),
|
||||
// Blank rather than repeated when the counter has not been renamed: the
|
||||
// name column already IS the code then, and printing it twice on one row
|
||||
// is the kind of noise that makes a table look machine-generated.
|
||||
code: labels.isNamed(counter.terminalId) ? counter.terminalId : '',
|
||||
branch: branchLabel(counter.branchName),
|
||||
bills: counter.periodBills,
|
||||
amount: counter.periodAmount,
|
||||
lastSale: counter.status.lastBillAt ? clockTime(counter.status.lastBillAt) : '—',
|
||||
terminalId: counter.terminalId,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<FineRowData>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Counter',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
{row.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Card>
|
||||
{row.code ? (
|
||||
<Text
|
||||
type="body"
|
||||
size="xsm"
|
||||
style={{ color: 'var(--color-ink-4)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{row.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'branch',
|
||||
header: 'Branch',
|
||||
width: { type: 'proportional', value: 2 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
{row.branch}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bills',
|
||||
header: 'Sales',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 90 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" hasTabularNumbers>
|
||||
{row.bills}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Taken',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 120 },
|
||||
renderCell: (row) => (
|
||||
<Text type="label" size="sm" hasTabularNumbers>
|
||||
{rupees(row.amount)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSale',
|
||||
header: 'Last sale',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Text type="body" size="sm" color="secondary" hasTabularNumbers>
|
||||
{row.lastSale}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
header: '',
|
||||
align: 'end',
|
||||
width: { type: 'pixel', value: 100 },
|
||||
renderCell: (row) => (
|
||||
<Button
|
||||
label="Rename"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const found = counters.find((counter) => counter.key === row.key);
|
||||
if (found) setRenaming(found);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={1.5}>
|
||||
{renaming ? (
|
||||
<Card padding={0} variant="transparent">
|
||||
<div style={{ ...panelStyle, padding: 14 }}>
|
||||
<CounterName
|
||||
card={renaming}
|
||||
labels={labels}
|
||||
isRenaming
|
||||
setIsRenaming={() => setRenaming(null)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card padding={0} variant="transparent">
|
||||
<div style={{ ...panelStyle, overflow: 'hidden' }}>
|
||||
<Table<FineRowData>
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="key"
|
||||
density="balanced"
|
||||
hasHover
|
||||
dividers="rows"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -486,14 +692,19 @@ function FineRow({ counter, labels }: { counter: HealthyCounter; labels: Counter
|
||||
|
||||
function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={1} style={{ ...panelStyle, padding: 16 }}>
|
||||
{labels.hidden.map((terminalId) => (
|
||||
<HStack key={terminalId} justify="between" align="center" gap={2}>
|
||||
<Text type="body" size="xsm" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{labels.nameFor(terminalId)}
|
||||
</Text>
|
||||
<SmallButton label="Bring back" onClick={() => labels.unhide(terminalId)} />
|
||||
<Button
|
||||
label="Bring back"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => labels.unhide(terminalId)}
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
<div style={{ height: 1, background: 'var(--color-line)' }} />
|
||||
@@ -510,8 +721,8 @@ function HiddenList({ labels }: { labels: CounterLabels }) {
|
||||
|
||||
function Empty({ checked }: { checked: string[] }) {
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={0.5} padding={3}>
|
||||
<Card padding={0} variant="transparent">
|
||||
<VStack gap={0.5} style={{ ...panelStyle, padding: 20 }}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
No counter has reported yet
|
||||
</Text>
|
||||
@@ -529,38 +740,3 @@ function Empty({ checked }: { checked: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bits ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function SmallButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
height: 26,
|
||||
padding: '0 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--color-line)',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-ink-3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,11 +56,15 @@ export const BUCKET_LABEL: Record<Bucket, string> = {
|
||||
|
||||
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
|
||||
|
||||
export const BUCKET_COLOR: Record<Bucket, string> = {
|
||||
now: 'var(--color-error, #d64545)',
|
||||
look: 'var(--color-warning, #b7860b)',
|
||||
fine: 'var(--color-success, #10b981)',
|
||||
};
|
||||
/* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a
|
||||
rail down each card and a dot beside each heading.
|
||||
|
||||
It is gone rather than moved. The console does not colour-code severity:
|
||||
`KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour
|
||||
ramp existed on this page and nowhere else in the product. Urgency is already
|
||||
carried twice over — by the group's name, in plain words, and by the order the
|
||||
groups appear in — so the colour was a third telling, in the loudest register
|
||||
available, on the one page a supervisor opens when they are already worried. */
|
||||
|
||||
export interface ProblemContext {
|
||||
/** Evaluation instant, so a whole board is judged against one clock. */
|
||||
|
||||
@@ -64,6 +64,28 @@ export interface Board {
|
||||
* headline that cries wolf is the one thing this page cannot afford.
|
||||
*/
|
||||
strandedBills: number;
|
||||
/**
|
||||
* What every counter in scope rang in the selected period.
|
||||
*
|
||||
* From the sales split, not from the heartbeats: a till reports its own
|
||||
* running total, and two tills whose clocks disagree would otherwise be added
|
||||
* together into a figure the books never saw.
|
||||
*
|
||||
* There is deliberately NO "amount stuck" twin. The health record carries
|
||||
* `pending_bills` — a count — and no value, so the money sitting on an
|
||||
* unreachable till is a number this backend cannot tell us. The page says how
|
||||
* many sales are waiting and stops there rather than estimating.
|
||||
*/
|
||||
takenAmount: number;
|
||||
takenBills: number;
|
||||
/**
|
||||
* How many COUNTERS need attention — not how many cards are on screen.
|
||||
*
|
||||
* A counter with a problem in both buckets produces two cards, so counting
|
||||
* cards against `total` reads "5 of 4 counters". Distinct terminals is the
|
||||
* only version of this number that can be shown beside a total.
|
||||
*/
|
||||
needCounters: number;
|
||||
isLoading: boolean;
|
||||
/** Branches whose health read FAILED — not branches with no counters. */
|
||||
failed: string[];
|
||||
@@ -243,12 +265,42 @@ export function useTerminalBoard({ branches, range, isHidden }: BoardOptions): B
|
||||
|
||||
const inBucket = (bucket: Bucket) => cards.filter((card) => card.problem.bucket === bucket);
|
||||
|
||||
// Summed per COUNTER, not per card. A counter with a problem in both
|
||||
// buckets produces two cards carrying the same period figures, so adding
|
||||
// the cards up would bill it twice — and it would be the counters in the
|
||||
// worst shape that got double-counted, which is the wrong direction for a
|
||||
// number the page leads with.
|
||||
const takings = new Map<string, { bills: number; amount: number }>();
|
||||
for (const card of cards) {
|
||||
takings.set(`${card.branchId}:${card.terminalId}`, {
|
||||
bills: card.periodBills,
|
||||
amount: card.periodAmount,
|
||||
});
|
||||
}
|
||||
for (const counter of fine) {
|
||||
takings.set(`${counter.branchId}:${counter.terminalId}`, {
|
||||
bills: counter.periodBills,
|
||||
amount: counter.periodAmount,
|
||||
});
|
||||
}
|
||||
const needing = new Set(cards.map((card) => `${card.branchId}:${card.terminalId}`));
|
||||
|
||||
let takenBills = 0;
|
||||
let takenAmount = 0;
|
||||
for (const entry of takings.values()) {
|
||||
takenBills += entry.bills;
|
||||
takenAmount += entry.amount;
|
||||
}
|
||||
|
||||
return {
|
||||
now: inBucket('now'),
|
||||
look: inBucket('look'),
|
||||
fine,
|
||||
total,
|
||||
strandedBills,
|
||||
takenAmount,
|
||||
takenBills,
|
||||
needCounters: needing.size,
|
||||
isLoading: health.some((query) => query.isLoading),
|
||||
failed,
|
||||
checked: branches.map((branch) => branch.locationname),
|
||||
|
||||
120
vite.config.ts
120
vite.config.ts
@@ -1,37 +1,97 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
export default defineConfig(({ mode }) => {
|
||||
/**
|
||||
* A build stamp, shown at the foot of the account menu.
|
||||
* Secrets for the dev proxy, read WITHOUT the `VITE_` prefix on purpose.
|
||||
*
|
||||
* This exists because of a real and expensive failure: work was verified here,
|
||||
* reported as done, and looked at on a machine running an older copy — twice,
|
||||
* over four days, with no way for either side to tell. A dev server and a
|
||||
* stale folder are indistinguishable from the screen. Now they are not.
|
||||
* Vite only exposes `VITE_`-prefixed variables to client code, so anything
|
||||
* named plainly here stays in the Node process running the dev server and
|
||||
* never reaches the bundle. The ingest service's token is injected into the
|
||||
* proxied request below, server-side, exactly the way the old console handles
|
||||
* its Hasura admin secret.
|
||||
*
|
||||
* Put it in `.env.local` (gitignored):
|
||||
*
|
||||
* INGEST_TOKEN=...
|
||||
*/
|
||||
define: {
|
||||
__BUILD_STAMP__: JSON.stringify(
|
||||
new Date().toISOString().replace('T', ' ').slice(0, 16).concat(' UTC'),
|
||||
),
|
||||
},
|
||||
resolve: { alias: { '@': path.resolve(import.meta.dirname, './src') } },
|
||||
server: {
|
||||
// 3100, not 3000: the OLD console (daily_merchant_web) runs its dev server
|
||||
// on 3000. Sharing a port means whichever starts first wins it, and you end
|
||||
// up looking at the wrong app while wondering why nothing changed.
|
||||
port: 3100,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/fiesta': {
|
||||
target: 'https://fiesta.nearle.app',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (p) => p.replace(/^\/fiesta/, ''),
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const ingestToken = (env["INGEST_TOKEN"] ?? "").trim();
|
||||
const ingestHeader = (env["INGEST_AUTH_HEADER"] ?? "Authorization").trim();
|
||||
const ingestScheme = (env["INGEST_AUTH_SCHEME"] ?? "Bearer").trim();
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
/**
|
||||
* A build stamp, shown at the foot of the account menu.
|
||||
*
|
||||
* This exists because of a real and expensive failure: work was verified here,
|
||||
* reported as done, and looked at on a machine running an older copy — twice,
|
||||
* over four days, with no way for either side to tell. A dev server and a
|
||||
* stale folder are indistinguishable from the screen. Now they are not.
|
||||
*/
|
||||
define: {
|
||||
__BUILD_STAMP__: JSON.stringify(
|
||||
new Date().toISOString().replace("T", " ").slice(0, 16).concat(" UTC"),
|
||||
),
|
||||
},
|
||||
resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src") } },
|
||||
server: {
|
||||
// 3100, not 3000: the OLD console (daily_merchant_web) runs its dev server
|
||||
// on 3000. Sharing a port means whichever starts first wins it, and you end
|
||||
// up looking at the wrong app while wondering why nothing changed.
|
||||
port: 3100,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/fiesta": {
|
||||
target: "https://fiesta.nearle.app",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (p) => p.replace(/^\/fiesta/, ""),
|
||||
},
|
||||
/**
|
||||
* The catalogue ingest service.
|
||||
*
|
||||
* Proxied for the same reason as Fiesta, plus one more: this is a
|
||||
* different origin entirely, so calling it from the browser would need
|
||||
* `Access-Control-Allow-Origin` for localhost:3100 on their side. Going
|
||||
* through the dev server makes it same-origin and removes the question
|
||||
* from development.
|
||||
*
|
||||
* It does NOT remove it from production. A deployed console calls the
|
||||
* service directly, so CORS has to be configured there before this ships
|
||||
* — otherwise it works on every developer machine and fails the moment it
|
||||
* is deployed, which is the worst order to find out.
|
||||
*/
|
||||
"/ingest": {
|
||||
target: "https://mcp.nearle.ai.in",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (p) => p.replace(/^\/ingest/, ""),
|
||||
configure: (proxy) => {
|
||||
// The credential is attached HERE, not in the app.
|
||||
//
|
||||
// The service answered the first real upload with 401, so it wants
|
||||
// one. A token the browser holds is a token you have published — the
|
||||
// bundle is served to anyone who opens the console — so it is added
|
||||
// on this side of the proxy and the client never sees it.
|
||||
//
|
||||
// This covers development only. A deployed console talks to the
|
||||
// service directly, and the same reasoning says the token cannot
|
||||
// travel with it: production needs the call relayed through Fiesta,
|
||||
// or an endpoint that accepts the console user's own session.
|
||||
proxy.on("proxyReq", (proxyReq) => {
|
||||
if (!ingestToken) return;
|
||||
proxyReq.setHeader(
|
||||
ingestHeader,
|
||||
ingestScheme ? `${ingestScheme} ${ingestToken}` : ingestToken,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user