diff --git a/src/api/ingest.ts b/src/api/ingest.ts new file mode 100644 index 0000000..a245cb3 --- /dev/null +++ b/src/api/ingest.ts @@ -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; + /** 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; + unrecognised_columns: string[]; + rows: Record[]; + 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(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(response); +} + +async function readResponse(response: Response): Promise { + 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 { + return send('/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 { + const { file, useLlm = false, fetchImages = false, signal } = options; + const query = new URLSearchParams({ + use_llm: String(useLlm), + fetch_images: String(fetchImages), + }); + return send('/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 { + 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(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 { + 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(' · '); +} diff --git a/src/api/products.ts b/src/api/products.ts index 8e9c1ab..3386df2 100644 --- a/src/api/products.ts +++ b/src/api/products.ts @@ -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 { diff --git a/src/auth/roles.ts b/src/auth/roles.ts index 096d94f..aa3192a 100644 --- a/src/auth/roles.ts +++ b/src/auth/roles.ts @@ -48,10 +48,24 @@ export function resolveRole(user: Pick): 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, diff --git a/src/auth/session.ts b/src/auth/session.ts index 14c4739..acb9420 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -77,6 +77,60 @@ export async function login(email: string, password: string): Promise { + 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; diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index 6c7e38b..3333f59 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -675,11 +675,37 @@ const Rule = () => (
); +/** + * 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(); } diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index 599b9a1..4caa63d 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -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 ; + /** 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() { }} > - {setupUserid === null ? ( + {step !== 'setup' ? ( setIsPasswordVisible((visible) => !visible)} - onSubmit={handleSubmit} + onSubmit={step === 'email' ? handleEmail : handleSubmit} + onBack={restart} /> ) : ( setIsPasswordVisible((visible) => !visible)} onSubmit={handleSetup} - onBack={() => { - setSetupUserid(null); - setError(null); - }} + onBack={restart} /> )}
@@ -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

- 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}.`}

@@ -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' } + : {}), + }} /> + {step === 'password' ? ( onPassword(event.target.value)} placeholder="••••••••" autoComplete="current-password" + autoFocus required style={{ ...inputStyle, paddingRight: 40 }} /> + ) : null} {/* Neither "Keep me signed in" nor "Forgot password?" is here any more. @@ -399,11 +463,17 @@ function FormPanel({ + {step === 'password' ? ( + + ) : null} +

- @@ -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, diff --git a/src/features/nearle-admin/import/SheetImportPanel.tsx b/src/features/nearle-admin/import/SheetImportPanel.tsx index 06c3ff2..c94466f 100644 --- a/src/features/nearle-admin/import/SheetImportPanel.tsx +++ b/src/features/nearle-admin/import/SheetImportPanel.tsx @@ -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 { 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(null); const [parsed, setParsed] = useState(null); const [parseError, setParseError] = useState(null); - const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); - const [result, setResult] = useState(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(null); + /** The job while it runs, and after it settles. */ + const [job, setJob] = useState(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 ( - {isClean ? ( - - ) : ( + {broken ? ( + + ) : (outcome?.error_count ?? 0) + (outcome?.rejected ?? 0) > 0 ? ( + ) : ( + )} - {result.created} product{result.created === 1 ? '' : 's'} imported + {summarise(job)} - - {result.linked} linked to the store, {result.stocked} given opening stock. - {isClean ? '' : ` ${result.failures.length} row(s) did not make it.`} - + {outcome ? ( + <> + + {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(', ')}.` : ''} + - {!isClean ? ( - - {result.failures.slice(0, 10).map((failure, index) => ( - - - - {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 ? ( + + + Ignored columns - - ))} - - ) : null} + + {outcome.unrecognised_columns.join(', ')} — the service does not recognise these + headers, so nothing in them was read. + + + ) : null} + + {outcome.errors.length > 0 ? ( + + + {outcome.error_count} row{outcome.error_count === 1 ? '' : 's'} could not be + imported + + {outcome.errors.slice(0, 10).map((failure) => ( + + + + {failure.product_name ? `${failure.product_name} — ` : ''} + {failure.error} + + + ))} + {outcome.error_count > outcome.errors.length ? ( + + {outcome.error_count - outcome.errors.length} more not listed — the service + caps this list at 50. + + ) : null} + + ) : null} + + {outcome.rejections.length > 0 ? ( + + + {outcome.rejected} built but rejected by validation + + {outcome.rejections.slice(0, 10).map((rejection, index) => ( + + + + {rejection.product_name} — {rejection.reason} + + + ))} + + ) : null} + + {outcome.warnings.length > 0 ? ( + + + Corrections applied + + {outcome.warnings.slice(0, 10).map((warning, index) => ( + + {warning} + + ))} + + ) : 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 ? ( + + 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. + + ) : null} + + ) : ( + + {job.detail ?? 'The service reported no detail.'} + + )} + + - ) : null} - + {/* `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. */} + : } + onClick={onToggle ?? (() => {})} + /> + ), + } + : {})} + /> {!isCollapsible || isOpen ? {children} : null} ); @@ -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 ( - -

- - + +
+ {/* 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. */} + + {problem.bucket === 'now' ? : } + + + @@ -290,15 +346,25 @@ function ProblemCard({ card, labels }: { card: BoardCard; labels: CounterLabels ) : null} - setShowDetails((open) => !open)} /> - } onClick={() => setIsRenaming(true)} /> +
- } + variant="secondary" + size="sm" + icon={} onClick={() => { labels.rename(card.terminalId, draft); setIsRenaming(false); }} /> - setIsRenaming(false)} /> + - ); -} diff --git a/src/features/store-admin/terminalProblems.ts b/src/features/store-admin/terminalProblems.ts index aafae4c..7a52229 100644 --- a/src/features/store-admin/terminalProblems.ts +++ b/src/features/store-admin/terminalProblems.ts @@ -56,11 +56,15 @@ export const BUCKET_LABEL: Record = { export const BUCKET_RANK: Record = { fine: 0, look: 1, now: 2 }; -export const BUCKET_COLOR: Record = { - 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. */ diff --git a/src/features/store-admin/useTerminalBoard.ts b/src/features/store-admin/useTerminalBoard.ts index 6ed7ee5..9e3e565 100644 --- a/src/features/store-admin/useTerminalBoard.ts +++ b/src/features/store-admin/useTerminalBoard.ts @@ -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(); + 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), diff --git a/vite.config.ts b/vite.config.ts index d6e58a4..51dfdc8 100644 --- a/vite.config.ts +++ b/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, + ); + }); + }, + }, }, }, - }, + }; });