Files
sapl/frontend/src/components/BulkGarmentUpload.tsx
T
sapl-admin 7eae9482c6 feat(library): pattern number optional, style number mandatory
The garment's identity is its style number (QR scan URL, hangtag, every TNA
join key). Pattern number is a pattern-master reference that routinely lands
after the sample, so it must not gate an approval.

- contract: MANDATORY_FIELDS = (style_number, fabric_quality, counters) in
  services/qr_fields.py, mirrored by frontend/src/lib/qrFields.ts; the
  QR_MANDATORY_FIELDS / missing_qr_fields names stay as aliases so the gates,
  the QR endpoint and the CI test list keep working.
- approval gates + POST /api/plm/{id}/qr now require the style number; it is
  listed exactly once (no double-listing in the refusal payload).
- closed the identity hole in PUT /api/garments/{id}: blank, whitespace-only
  and explicit JSON null are all 422 STYLE_NUMBER_REQUIRED and the row is left
  untouched. The edit form used to send null when the field was emptied, which
  silently erased the number.
- form: Style number carries the "· required" marker and refuses to save;
  Pattern no. loses its "· QR" marker and is marked optional.
- capture drafts are safe: garment_capture_save_service reserves a style number
  at save time, so no draft is blocked by the new rule.
- tests: inverted the null-clears-the-number case, added pattern-less-approval
  and per-field blocking coverage.

Existing style-less rows are deliberately left un-approvable until someone
fills the number in (no backfill, no data writes).
2026-09-28 12:24:13 +05:30

1925 lines
105 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BulkDuplicateMatch, BulkPreviewGroup, BulkPreviewShot, BulkSaveResponse } from '../api/client';
import { previewGarmentCapture, saveGarmentCapture, isCaptureJobTerminal, parseImageDuplicateDetail, getGarment, imageUrl } from '../api/client';
import { Link } from 'react-router-dom';
import { PageHeader } from './editorial/page';
import Dropdown from './shared/Dropdown';
import ConfirmDialog from './shared/ConfirmDialog';
import { resolveDesignCity, rememberCity, DESIGN_CITIES } from '../lib/designCity';
import { reasonDetail, moreMatches, candidateLabel } from '../lib/duplicates';
import CameraCaptureModal from './shared/CameraCaptureModal';
import RapidCapturePanel from './shared/RapidCapturePanel';
import type { CapturedShot } from './shared/RapidCapturePanel';
import { CAPTURE_POLICY, shrinkShotsForUpload, isUploadableImage, UPLOAD_IMAGE_EXTENSIONS } from '../lib/photoCapture';
import { UploadLottie } from './LottieLoader';
import { useToast } from '../context/ToastContext';
import { enqueueCaptureJob, pollCaptureJob, retryCaptureJob } from '../lib/captureJobSender';
import { queueAll, queueUpdate, type CaptureQueueRecord } from '../lib/captureQueue';
import { apiError } from '../lib/apiError';
import PushNotificationsToggle from './PushNotificationsToggle';
import CaptureJobSheet from './CaptureJobSheet';
import QrPrinterModal from './qr/QrPrinterModal';
import ColourPaletteInput from './shared/ColourPaletteInput';
import { AI_FIELD_ORDER, parseColourValue, serializeColour, type ColourEntry } from '../lib/aiFields';
import { missingQrFields, qrMissingSentence } from '../lib/qrFields';
import { QR_BLOCKED_BOX, QR_BLOCKED_GLYPH, QR_READY_GLYPH, QR_READY_TEXT, QR_REQUIRED_TEXT } from '../lib/qrStyles';
import { captureResultsPath } from '../lib/captureResults';
// Bulk garment upload (issue #439) — pick up to 50 raw shots, let the backend
// AI-group them into garments, then review/split/merge those groups and batch
// save them to the library. Re-uses the app-native shared Dropdown (no native
// <select>) and the toast/confirm conventions from the phase-1 capture review.
// The six shot-role options from the verified capture contract (issue #435/#439)
// live in the shared module — the bulk flow and the review-stage role display
// (#610) must not drift apart.
import { SHOT_ROLE_OPTIONS } from '../lib/shotRoles';
const MAX_FILES = 50;
const MAX_BYTES = CAPTURE_POLICY.maxBytes; // shared capture policy (mirrors backend)
const CATEGORY_OPTIONS = [
{ value: 'mens', label: 'Mens' },
{ value: 'womens', label: 'Womens' },
{ value: 'kids', label: 'Kids' },
];
// Bulk preview returns AI-extracted taxonomy per group (ai_fields mapping
// above pre-fills the form); these extra tag inputs remain for anything the
// user wants to add or correct before save.
// Colour is NOT one of these plain text inputs: the palette is a structured
// list (name + hex + role) and is edited by ColourPaletteInput, which sends the
// array itself on save (#651/#652).
const TAG_FIELDS = [
{ key: 'silhouette', label: 'Silhouette' },
{ key: 'neckline', label: 'Neckline' },
{ key: 'sleeve', label: 'Sleeve' },
];
const DEFAULT_SEASON = 'AW26';
interface GroupFields {
segment: string; // new_dev | shopping (mirror single upload)
category: string; // mens | womens | kids (required to auto-number)
season: string;
collection: string;
description: string;
tags: Record<string, string>;
// Structured colour palette (name + hex + role) — saved under tags.colour as
// the validated array, never flattened to "Off White, Black" (#652).
palette: ColourEntry[];
// Same metadata fields the single-upload flow collects (QR-ready set).
fabricComposition: string;
fabricQuality: string;
patternNumber: string;
counters: string;
designCity: string;
isCoord: boolean;
}
/** QR readiness of one capture group's metadata — the same fields the Review Queue
* refuses to approve a garment without (lib/qrFields mirrors the backend). The
* capture itself is never blocked; this is what tells the uploader, while the
* sample's hangtag is still in hand, why a later approval would stop.
*
* `styleNumberAuto`: this flow reserves the group's style number server-side at
* SAVE time, so it is never the uploader's job to type one — passing the flag
* keeps the note from accusing the form of a field it does not own. */
function qrMissingFor(fields: GroupFields): string[] {
return missingQrFields({
fabric_quality: fields.fabricQuality,
pattern_number: fields.patternNumber,
counters: fields.counters.trim() === '' ? null : Number(fields.counters),
}, { styleNumberAuto: true });
}
/** '· QR' marker on a mandatory field's label, amber + 'required' while it is empty. */
function QrMark({ missing, field }: { readonly missing: string[]; readonly field: string }) {
return missing.includes(field)
? <span className={'normal-case tracking-normal ' + QR_REQUIRED_TEXT}>· QR · required</span>
: <span className="normal-case tracking-normal text-muted/70">· QR</span>;
}
/** The readiness line under the mandatory fields of one group. */
function QrReadinessNote({ gid, fields }: { readonly gid: number; readonly fields: GroupFields }) {
const missing = qrMissingFor(fields);
if (missing.length === 0) {
return (
<p className={'font-plex text-[9px] uppercase tracking-[0.1em] -mt-2 ' + QR_READY_TEXT} data-testid={`bk-qr-ready-${gid}`}>
<span aria-hidden="true">{QR_READY_GLYPH}</span> QR-ready — quality · counters filled
</p>
);
}
return (
<p className={'font-plex text-[10px] leading-snug px-2.5 py-2 -mt-2 ' + QR_BLOCKED_BOX} data-testid={`bk-qr-blocked-${gid}`}>
<span aria-hidden="true">{QR_BLOCKED_GLYPH}</span> QR not ready — {qrMissingSentence(missing)} {missing.length === 1 ? 'is' : 'are'} empty. This capture still saves (to the Review Queue), but the garment can’t be approved until {missing.length === 1 ? 'it is' : 'they are'} filled.
</p>
);
}
interface LocalGroup {
id: number;
shots: number[]; // preview shot indices (original capture order)
fields: GroupFields;
aiError?: { code: string; message: string } | null; // tagging failed — why
// F5 (#514): raw AI buckets kept to detect the all-skipped state (no
// auto_save AND no needs_review entries) and show the fill-manually hint.
aiTagsEmpty?: boolean;
// #689: the RAW tagging answer for this group, kept so the save can attribute
// a value the reviewer did not touch to the model (source "ai" + its
// confidence) instead of storing every capture-born tag as human 100%.
aiTags?: Record<string, { value?: unknown; confidence?: number }>;
aiReview?: Record<string, { value?: unknown; confidence?: number }>;
// Issue #530: advisory image-duplicate matches from the preview, closest
// first. Empty = no near-identical garment found.
duplicateMatches: BulkDuplicateMatch[];
}
interface ShotMeta {
role: string;
confidence: number;
// Issue #534 Path A (advisory, from the preview response): number of DISTINCT
// foreground garments the VLM saw, and the blur gate (sharpness score + flag).
garmentCount?: number | null;
sharpness?: number | null;
blurry?: boolean;
}
/** Per-file capture metadata tracked in parallel to `files` so the preview
* upload can send taken_at + burst_index (#535/#532). burstIndex is null for
* library picks (no burst tracker) → legacy pHash grouping preserved. */
interface ShotCaptureMeta {
takenAt: string;
burstIndex: number | null;
}
/** The taxonomy the tagger fills at capture time — everything the canonical
* field order carries except `category` (saved as the segment scalar) and
* `colour` (saved as the structured palette below). #689: the capture sheet used
* to keep ONLY silhouette/neckline/sleeve, so the model's design_dna,
* fabric_family, closure, hem… were extracted and then dropped on save — every
* capture-born garment was born without a Design DNA. */
const CAPTURE_TAG_FIELDS = AI_FIELD_ORDER.filter((f) => f !== 'category' && f !== 'colour');
function emptyFields(): GroupFields {
return {
segment: 'new_dev', category: '', season: DEFAULT_SEASON, collection: 'TRANS AW26 Womenswear', description: '',
tags: Object.fromEntries(CAPTURE_TAG_FIELDS.map((f) => [f, ''])) as Record<string, string>,
palette: [],
fabricComposition: '', fabricQuality: '', patternNumber: '', counters: '', designCity: DESIGN_CITY_DEFAULT, isCoord: false,
};
}
// Auto-detected design city (last-used → geolocation → HQ). Resolved once per
// mount and applied to every group; the dropdown stays fully editable and the
// chosen city is remembered on save for next time.
const DESIGN_CITY_DEFAULT = 'unknown'; // pre-detection placeholder
let detectedCity: DesignCityType | null = null;
type DesignCityType = (typeof DESIGN_CITIES)[number] | 'unknown';
// Map the AI extraction (same TaggingService as the single-upload flow) into
// the bulk form fields. Category: the taxonomy "category" value (e.g. "Polo")
// is a garment TYPE, not our mens/womens/kids segment — map it via keyword
// hints, and prefer the AI segment hint if the model returns one. Description:
// built from the taxonomy fields the model read (type + colour + silhouette).
// Everything stays editable — the user reviews before save.
// Some taxonomy values are structured objects, not strings — e.g. colour can be
// { colour_name, hex, role } or a list of those. Flatten to readable text
// (the [object Object] bug was String() over one of these). The colour palette
// is ALSO parsed structurally by parseColourValue below (#651) — this text is
// only for the human-readable description and the tag inputs.
function aiValueText(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
if (Array.isArray(v)) return v.map(aiValueText).filter(Boolean).join(', ');
if (typeof v === 'object') {
const o = v as Record<string, unknown>;
return (
[o.colour_name, o.value, o.name, o.label]
.filter((x) => typeof x === 'string' && x)
.join(' ') || ''
);
}
return '';
}
function aiFields(g: BulkPreviewGroup): GroupFields {
const base = emptyFields();
const auto = g.ai_tags ?? {};
const review = g.ai_tags_review ?? {};
const pick = (field: string): string => {
const raw = auto[field] ?? review[field];
// entries look like { value: ..., confidence } but value itself may be structured
return aiValueText((raw as { value?: unknown } | undefined)?.value ?? raw);
};
// Same lookup without the readable-text flattening — the colour palette is
// parsed structurally (name + hex + role) instead (#651/#652).
const pickRaw = (field: string): unknown => {
const raw = auto[field] ?? review[field];
return (raw as { value?: unknown } | undefined)?.value ?? raw;
};
const type = pick('category'); // e.g. "Polo", "Dress", "Kurta"
const colour = pick('colour');
const silhouette = pick('silhouette');
const fabric = pick('fabric_texture');
// Segment mapping from garment type keywords (SAPL category facet is
// mens/womens/kids). Default bias: a bare "shirt" (and other unmarked
// types) maps to MENS — the user can always change it manually for a
// womens shirt (user rule: pre-fill, never lock).
const t = type.toLowerCase();
let seg = '';
if (/(kurta|saree|sari|kurti|lehenga|salwar|anarkali|dress|gown|skirt|blouse|top\b)/.test(t)) seg = 'womens';
if (/(sherwani|dhoti)/.test(t)) seg = 'mens';
if (!seg && t) seg = 'mens'; // shirt/polo/t-shirt/jeans/… default to mens
base.category = seg;
// Description: readable one-liner from what the AI actually saw.
const bits = [type, colour, silhouette && `${silhouette} silhouette`, fabric && `${fabric} fabric`].filter(Boolean);
base.description = bits.length ? bits.join(' · ') : '';
// Tags pre-fill (auto_save preferred, needs_review as fallback).
for (const k of Object.keys(base.tags)) {
const v = pick(k);
if (v) base.tags[k] = v;
}
// Colour palette, structurally: names + hexes + roles as a list. The flattened
// `colour` text above still feeds the description, but the palette is what is
// saved under tags.colour (#652).
base.palette = parseColourValue(pickRaw('colour'));
return base;
}
type Phase = 'select' | 'review';
// Human label for a background job's status chip.
function jobStatusLabel(status: string): string {
switch (status) {
case 'queued': return 'Queued';
case 'processing': return 'Processing';
case 'done': return 'Done';
case 'partial': return 'Partly done';
case 'failed': return 'Failed';
default: return status;
}
}
// Issue #530: advisory image-duplicate comparison. The preview match carries no
// image, so the existing garment's stored photo is fetched lazily by id the
// first time the badge is expanded — rendered next to the group's own shot.
function DuplicateCompare({ localUrl, match }: { readonly localUrl?: string; readonly match: BulkDuplicateMatch }) {
const [image, setImage] = useState<string | null | undefined>(undefined); // undefined = loading, null = none
useEffect(() => {
let alive = true;
getGarment(match.garment_id)
.then((g) => { if (alive) setImage(g.image_path ?? null); })
.catch(() => { if (alive) setImage(null); });
return () => { alive = false; };
}, [match.garment_id]);
return (
<div data-testid={`dup-compare-${match.garment_id}`} className="flex items-stretch gap-4">
<figure className="w-20 shrink-0 m-0">
{localUrl
? <img src={localUrl} alt="Your photo" className="w-20 h-24 object-cover border border-line" />
: <div className="w-20 h-24 border border-line bg-ivory" />}
<figcaption className="font-plex text-[8px] uppercase tracking-wide text-muted text-center mt-1">This batch</figcaption>
</figure>
<figure className="w-20 shrink-0 m-0">
{image === undefined
? <div className="w-20 h-24 border border-line bg-ivory animate-pulse" />
: image
? <img src={imageUrl(image)} alt={match.style_number} className="w-20 h-24 object-cover border border-line" />
: <div className="w-20 h-24 border border-line bg-ivory flex items-center justify-center font-plex text-[8px] text-muted text-center px-1">No image</div>}
<figcaption className="font-plex text-[8px] uppercase tracking-wide text-muted text-center mt-1">{match.style_number}</figcaption>
</figure>
<div className="flex flex-col justify-center gap-1">
<span className="font-plex text-[9px] uppercase tracking-[0.12em] text-muted">pHash Δ</span>
{/* #831: phash_distance is null when the match came from the semantic
leg only — an em dash, never the word "null". */}
<span className="font-display text-[15px] text-ink leading-none">{match.phash_distance ?? '—'}</span>
</div>
</div>
);
}
const inputCls = 'w-full h-10 px-3 bg-paper border border-line text-[13px] outline-none transition-colors placeholder:text-muted/60';
const textareaCls = 'w-full px-3 py-2.5 bg-paper border border-line text-[13px] outline-none transition-colors resize-none placeholder:text-muted/60 leading-relaxed';
const labelCls = 'block font-plex text-[9px] uppercase tracking-[0.14em] text-muted mb-1.5';
const btnPrimary = 'h-11 px-6 bg-ink text-paper text-[13px] tracking-wide hover:opacity-90 transition-opacity disabled:opacity-30 disabled:cursor-not-allowed';
const btnGhost = 'h-10 px-4 text-[12px] bg-ivory border border-line hover:border-ink/30 transition-colors';
// Capture-mode switch (rapid / picker / 1-garment / instant). Phones get a
// 44px-tall, equal-share row set so the two wrapped lines end flush right
// instead of zig-zagging (chips were h-8 = 32px with ragged right edges);
// lg+ keeps the original 32px, content-sized row.
const modeChipCls = 'h-11 flex-1 whitespace-nowrap px-3 font-plex text-[10px] uppercase tracking-[0.12em] transition-colors lg:h-8 lg:flex-none';
export default function BulkGarmentUpload() {
const toast = useToast();
const [files, setFiles] = useState<File[]>([]);
const [urls, setUrls] = useState<string[]>([]);
const [phase, setPhase] = useState<Phase>('select');
// Auto-detect design city once per mount (last-used → geo → HQ) and apply to
// all current + future groups. Fire-and-forget: the form is usable before it
// resolves, and the dropdown remains editable regardless.
useEffect(() => {
let alive = true;
resolveDesignCity().then((city) => {
if (!alive) return;
detectedCity = city;
setGroups((gs) => (gs.length ? gs.map((g) => ({ ...g, fields: { ...g.fields, designCity: city } })) : gs));
});
return () => { alive = false; };
}, []);
const [busy, setBusy] = useState<'grouping' | 'saving' | null>(null);
// Desktop in-app camera capture (mobile uses its native picker).
const [cameraOpen, setCameraOpen] = useState(false);
const [cameraShots, setCameraShots] = useState(0);
// Rapid capture mode (issue #508): camera-first panel with a shot strip.
const [rapidMode, setRapidMode] = useState(false);
// Zero-touch "Instant background" mode (#555): when ON, rapid-camera shots are
// handed straight to the background auto-save worker — no "Review N shots"
// button, no review wait. Default ON (the whole point of rapid capture).
const [instantMode, setInstantMode] = useState(true);
// Compact persistent chip ("N shots · grouping garments in background") shown
// from hand-off until the auto-save job reaches a terminal status.
const [instantChip, setInstantChip] = useState<{ shots: number } | null>(null);
// Optional BG removal (user's call; default off) — hero shot of each group.
const [removeBg, setRemoveBg] = useState(false);
const [error, setError] = useState<string | null>(null);
// Rapid-capture background jobs (issue #523): the capture mode used for the
// current grouping (sent in the job payload), the jobs still being processed,
// and the "finished, ready for review" reconciliation banner.
const [captureMode, setCaptureMode] = useState<'camera' | 'library'>('library');
// #688: "1 garment" — the user asserts every shot belongs to ONE garment, so
// the preview skips AI grouping and returns a single group. Default OFF keeps
// the AI-grouping flow untouched.
const [oneGarment, setOneGarment] = useState(false);
const [queuedJobs, setQueuedJobs] = useState<CaptureQueueRecord[]>([]);
const [dismissedJobs, setDismissedJobs] = useState<Set<string>>(new Set());
// (#562 follow-up) Tappable chip → live status sheet for one background job.
const [sheetJob, setSheetJob] = useState<CaptureQueueRecord | null>(null);
// #688: garment selected for per-garment QR tag printing from the saved panels.
const [qrGarment, setQrGarment] = useState<string | null>(null);
const [reconcile, setReconcile] = useState<{ ready: number; failed: string[]; drafts?: number; duplicates?: number; duplicateStyle?: string | null; zeroTouch?: boolean } | null>(null);
const [previewId, setPreviewId] = useState<string | null>(null);
const [groups, setGroups] = useState<LocalGroup[]>([]);
const [unassigned, setUnassigned] = useState<number[]>([]);
const [shotMeta, setShotMeta] = useState<Record<number, ShotMeta>>({});
const [selGroups, setSelGroups] = useState<Set<number>>(new Set());
const [selUnassigned, setSelUnassigned] = useState<Set<number>>(new Set());
const [openTags, setOpenTags] = useState<Set<number>>(new Set());
const [missingCat, setMissingCat] = useState<Set<number>>(new Set());
const [saveResult, setSaveResult] = useState<BulkSaveResponse | null>(null);
// #843: resp.possible_duplicates[].group_index indexes the payload we SENT —
// the same contract failed[] uses — so remember that index → local group id
// mapping from the last save. The success panel renders long after
// handleSave's local `pending` array is gone, and every path that clears
// saveResult also stops rendering this list, so a stale mapping is harmless.
const [sentGroupIds, setSentGroupIds] = useState<number[]>([]);
// HG-F1 (#514): group ids already persisted server-side — excluded from
// retry payloads so a partial save is resumable instead of stuck forever.
const [savedGroupIds, setSavedGroupIds] = useState<Set<number>>(new Set());
// Indices (into files[]) flagged NOT_A_GARMENT by the server — rendered as
// red-ringed thumbnails with a badge until the user removes or re-groups.
const [rejectedIdx, setRejectedIdx] = useState<Set<number>>(new Set());
// Issue #530: group ids whose duplicate-compare panel is expanded, and the
// pending IMAGE_DUPLICATE 409 surfaced in the "save anyway" override dialog.
const [dupOpen, setDupOpen] = useState<Set<number>>(new Set());
const [dupConflict, setDupConflict] = useState<{ message: string; styleNumber: string; distance: number; groupId: number | null } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const nextId = useRef(10_000); // fresh-group ids never clash with AI group_index
const urlsRef = useRef<string[]>([]);
urlsRef.current = urls;
// Per-file capture metadata, index-aligned with `files`. A ref (not state):
// it never drives UI, and is written synchronously so the rapid-capture
// Done handler can read it immediately after addFiles.
const shotsMetaRef = useRef<ShotCaptureMeta[]>([]);
// Zero-touch instant hand-off (#555): the debounce timer, the latest shots the
// rapid panel reported (so leaving the view can fire immediately), and an
// in-flight guard so a debounce + a Done tap can never double-submit.
const instantTimer = useRef<number | null>(null);
const latestRapidShots = useRef<CapturedShot[]>([]);
const instantInFlight = useRef(false);
// Revoke any live object URLs when the component unmounts (URLs are revoked
// eagerly on remove/clear; this is the leak guard for a mid-flow unmount).
useEffect(() => {
const live = urlsRef.current;
return () => live.forEach((u) => URL.revokeObjectURL(u));
}, []);
function addFiles(list: FileList | File[] | null, meta?: ShotCaptureMeta[]) {
if (!list) return;
const incoming = Array.from(list);
// Admit only what the API can actually ingest, and say so out loud. A silent
// drop is indistinguishable from a broken picker: previously a .txt/.svg was
// filtered here with no toast, and a GIF/SVG slipped through `image/*` only
// to fail later with a server-side FILE_TYPE_NOT_SUPPORTED (issue #748).
// Files are also dropped before grouping, so no group is ever built on a
// file the server will reject.
const unsupported = incoming.filter((f) => !isUploadableImage(f));
if (unsupported.length) {
const names = unsupported.map((f) => f.name || f.type || 'file');
toast.error(
`${unsupported.length} file${unsupported.length > 1 ? 's' : ''} not supported (${names.join(', ')}) — use ${UPLOAD_IMAGE_EXTENSIONS.join(', ')}`,
);
}
// Attach capture metadata per file: caller-supplied (rapid panel) takes
// precedence; otherwise derive from lastModified (library picker / camera
// modal). burstIndex stays null for non-rapid paths (#532) so the backend
// keeps legacy pHash grouping.
const paired = incoming
.map((f, i) => ({
file: f,
meta: meta?.[i] ?? {
takenAt: new Date(f.lastModified || Date.now()).toISOString(),
burstIndex: null,
},
}))
.filter((p) => isUploadableImage(p.file));
if (!paired.length) return;
const rejected: string[] = [];
const oversized: string[] = [];
let accepted = paired.filter(({ file }) => {
if (file.size > MAX_BYTES) { oversized.push(file.name); return false; }
return true;
});
const room = MAX_FILES - files.length;
if (accepted.length > room) {
rejected.push(`${accepted.length - room} file${accepted.length - room > 1 ? 's' : ''} over the ${MAX_FILES}-shot limit`);
accepted = accepted.slice(0, room);
}
const mb = Math.round(MAX_BYTES / (1024 * 1024));
if (oversized.length) toast.error(`${oversized.length} file${oversized.length > 1 ? 's' : ''} over ${mb} MB (${oversized.join(', ')})`);
if (rejected.length) toast.error(rejected.join('; '));
if (!accepted.length) return;
const newUrls = accepted.map(({ file }) => URL.createObjectURL(file));
setFiles((p) => [...p, ...accepted.map(({ file }) => file)]);
setUrls((p) => [...p, ...newUrls]);
shotsMetaRef.current = [...shotsMetaRef.current, ...accepted.map(({ meta: m }) => m)];
setError(null);
setSaveResult(null);
setSavedGroupIds(new Set());
}
function removeFile(i: number) {
setUrls((p) => { URL.revokeObjectURL(p[i]); return p.filter((_, n) => n !== i); });
setFiles((p) => p.filter((_, n) => n !== i));
shotsMetaRef.current = shotsMetaRef.current.filter((_, n) => n !== i);
// Re-index: removing a shot shifts every later index down by one.
setRejectedIdx((p) => new Set([...p].filter((n) => n !== i).map((n) => (n > i ? n - 1 : n))));
}
function clearFiles() {
setUrls((p) => { p.forEach((u) => URL.revokeObjectURL(u)); return []; });
setFiles([]);
shotsMetaRef.current = [];
setRejectedIdx(new Set());
}
function clearBatch() {
// #688: the `1 garment` assertion is per-batch — a sticky ON would silently
// merge the NEXT batch's distinct garments into one style number.
setOneGarment(false);
urls.forEach((u) => URL.revokeObjectURL(u));
setUrls([]);
setFiles([]);
setPhase('select');
setPreviewId(null);
setGroups([]);
setUnassigned([]);
setShotMeta({});
setSelGroups(new Set());
setSelUnassigned(new Set());
setOpenTags(new Set());
setMissingCat(new Set());
setSaveResult(null);
setSavedGroupIds(new Set());
setBusy(null);
setError(null);
setDupOpen(new Set());
setDupConflict(null);
}
// ── Rapid-capture background jobs (issue #523) ─────────────────────────────
// Poll GET /jobs/:id every 15s while the tab is open; stop on terminal status.
// The interval map lives in a ref so it survives re-renders and is cleared on
// unmount (a background send must never leak a timer).
const pollTimers = useRef<Record<string, number>>({});
// A terminal job that hasn't been acknowledged yet → banner + toast, and mark
// it seen so the reconciliation never repeats for the same job.
const applyTerminal = useCallback((rec: CaptureQueueRecord) => {
if (rec.seen) return;
const gs = rec.groups_state ?? [];
const ready = rec.kind === 'job'
? (gs.length ? gs.filter((g) => g.status === 'done').length : (rec.group_count ?? 0))
: 0;
// Zero-touch (#555): drafts saved by the auto-save worker need a human
// confirm — surface the count in the same reconciliation banner.
const drafts = gs.filter((g) => g.status === 'done' && g.draft).length;
const failed = gs.filter((g) => g.status === 'failed').map((g) => g.error || 'A garment could not be processed.');
if (rec.kind === 'pending' && rec.last_error) failed.push(rec.last_error);
// #831/#832: a saved group that resembles one already in the library. The
// worker records these on the group (and in draft_reason), but this is the
// one moment the user is actually told the capture finished — and on the
// zero-touch path they are usually not watching the job sheet, so without
// this the match stayed invisible until the Review Queue much later.
// Advisory only: the garment is already saved, nothing is blocked.
const dupGroups = gs.filter((g) => (g.duplicate_matches?.length ?? 0) > 0);
const duplicates = dupGroups.length;
const duplicateStyle = dupGroups[0]?.duplicate_matches?.[0]?.style_number ?? null;
// refs #929 — only a zero-touch (auto_save) capture produces drafts, and
// drafts live in the Review queue; the classic job keeps the Library.
setReconcile({ ready, failed, drafts, duplicates, duplicateStyle, zeroTouch: rec.auto_save === true });
setInstantChip(null);
const dupNote = duplicates
? ` · ${duplicates} looks like ${duplicateStyle ?? 'a garment'} already in the library`
: '';
if (drafts > 0) {
toast.success(`Capture saved: ${ready} garment${ready === 1 ? '' : 's'} added to library, ${drafts} need review${dupNote}`);
} else {
toast.success(`Rapid capture finished: ${ready} garment${ready === 1 ? '' : 's'} ready for review${dupNote}`);
}
void queueUpdate(rec.job_token, { seen: true });
}, [toast]);
const startTracking = useCallback((token: string) => {
const timers = pollTimers.current;
if (timers[token]) return;
const tick = async () => {
const rec = await pollCaptureJob(token);
if (rec) {
setQueuedJobs((prev) => {
const i = prev.findIndex((r) => r.job_token === rec.job_token);
if (i === -1) return [...prev, rec];
return prev.map((r) => (r.job_token === rec.job_token ? rec : r));
});
}
if (rec && isCaptureJobTerminal(rec.status)) {
window.clearInterval(timers[token]);
delete timers[token];
applyTerminal(rec);
}
};
void tick();
timers[token] = window.setInterval(() => void tick(), 15_000);
}, [applyTerminal]);
useEffect(() => {
const timers = pollTimers.current;
return () => {
Object.values(timers).forEach((t) => window.clearInterval(t));
pollTimers.current = {};
if (instantTimer.current != null) window.clearTimeout(instantTimer.current);
};
}, []);
// On mount: resume polling for in-flight jobs and reconcile anything that
// finished while the user was away (toast + banner), marking it seen. The
// worker already saved the garments — this is the in-app catch-up for anyone
// who missed the notification bell.
useEffect(() => {
let alive = true;
void (async () => {
const all = await queueAll();
if (!alive) return;
const active = all.filter((r) => !isCaptureJobTerminal(r.status));
const finished = all.filter((r) => isCaptureJobTerminal(r.status) && !r.seen);
if (!alive) return;
setQueuedJobs(active);
finished.forEach((rec) => applyTerminal(rec));
active.filter((r) => r.kind === 'job' && r.job_id).forEach((r) => startTracking(r.job_token));
})();
return () => { alive = false; };
}, [applyTerminal, startTracking]);
// Enqueue every current group as ONE background capture job, then clear the
// editor immediately — the user can leave this screen right away.
async function handleSendToBackground() {
const invalid = groups.filter((g) => !g.fields.category).map((g) => g.id);
if (invalid.length) {
setMissingCat(new Set(invalid));
toast.error(`Set a category for ${invalid.length} garment${invalid.length > 1 ? 's' : ''} before sending`);
return;
}
if (unassigned.length > 0) {
toast.error(`${unassigned.length} shot${unassigned.length !== 1 ? 's' : ''} still unassigned — group or remove ${unassigned.length === 1 ? 'it' : 'them'} before sending`);
return;
}
if (!groups.length) return;
const count = groups.length;
const payload = {
groups: groups.map((g) => ({ shots: g.shots, fields: buildFieldsPayload(g.fields) })),
capture_mode: captureMode,
remove_bg: removeBg || undefined,
};
try {
const token = await enqueueCaptureJob(files.slice(), payload);
setQueuedJobs((prev) => [
...prev.filter((r) => r.job_token !== token),
{ job_token: token, job_id: null, status: 'queued', created_at: Date.now(), seen: false, kind: 'pending', group_count: count },
]);
startTracking(token);
const usedCity = groups[0]?.fields.designCity;
if (usedCity) rememberCity(usedCity);
clearBatch();
toast.success(`${count} garment${count === 1 ? '' : 's'} queued — you can leave this screen; we will notify you when they are ready`);
} catch (err) {
// enqueueCaptureJob never throws in practice; keep a guard so a surprise
// failure does not silently drop the batch.
toast.error(err instanceof Error ? err.message : 'Could not queue this capture — please try again.');
}
}
// ── Zero-touch instant hand-off (issue #555) ───────────────────────────────
// Rapid-camera shots go STRAIGHT to the background auto-save worker: no
// "Review N shots → group garments" button, no review wait. The server groups
// + tags + saves; clean groups land in the library, flagged ones become
// drafts. The user can close the app immediately.
function clearInstantTimer() {
if (instantTimer.current != null) {
window.clearTimeout(instantTimer.current);
instantTimer.current = null;
}
}
async function handoffInstant(shotFiles: File[], metas: ShotCaptureMeta[]) {
if (!shotFiles.length || instantInFlight.current) return;
instantInFlight.current = true;
clearInstantTimer();
latestRapidShots.current = [];
setInstantPendingCount(0);
const count = shotFiles.length;
// Shrink BEFORE the send: a 30-shot batch is ~40 MB as one multipart request
// and that upload dies mid-body on mobile — the batch then never reaches the
// server (UAT 2026-09-16). Order and count are preserved, so the metas below
// still line up index-for-index.
const uploadFiles = await shrinkShotsForUpload(shotFiles);
// Raw shots, no grouping: auto_save tells the worker to group server-side.
// taken_at/burst_index mirror what /preview sends today (#535/#532) so the
// server's grouping uses the real capture times.
const payload = {
groups: [{ shots: shotFiles.map((_, i) => i), fields: {} }],
capture_mode: 'camera' as const,
auto_save: true,
remove_bg: removeBg || undefined,
taken_at: metas.map((m) => m.takenAt ?? null),
burst_index: metas.map((m) => m.burstIndex),
};
try {
const token = await enqueueCaptureJob(uploadFiles, payload);
setQueuedJobs((prev) => [
...prev.filter((r) => r.job_token !== token),
{ job_token: token, job_id: null, status: 'queued', created_at: Date.now(), seen: false, kind: 'pending', group_count: count },
]);
setInstantChip({ shots: count });
startTracking(token);
clearBatch();
setRapidMode(false);
toast.success(`${count} shot${count === 1 ? '' : 's'} sent — grouping garments in the background. You can leave this screen.`);
} catch (err) {
// enqueueCaptureJob never throws in practice; keep a guard so a surprise
// failure does not silently drop the batch.
toast.error(err instanceof Error ? err.message : 'Could not send this capture — please try again.');
} finally {
instantInFlight.current = false;
}
}
// Fire the pending hand-off NOW (Done shooting / leaving the capture view).
function firePendingInstant() {
if (!instantMode) { latestRapidShots.current = []; return; }
const shots = latestRapidShots.current;
if (!shots.length) return;
latestRapidShots.current = [];
void handoffInstant(shots.map((s) => s.file),
shots.map((s) => ({ takenAt: s.takenAt, burstIndex: s.burstIndex })));
}
// Arm the pending-shots state when the rapid panel reports its shots on every
// change. #560: NO auto-timer — a 4s debounce fired while the user was still
// repositioning for the next garment, sending the batch to the background
// prematurely. Hand-off is now strictly user-confirmed via the panel's Done
// button (or the leave-view safety in firePendingInstant).
const [instantPendingCount, setInstantPendingCount] = useState(0);
function scheduleInstantHandoff(shots: CapturedShot[]) {
latestRapidShots.current = shots;
clearInstantTimer();
if (!instantMode) { setInstantPendingCount(0); return; }
setInstantPendingCount(shots.length);
}
function collectShotMeta(shots: BulkPreviewShot[]): Record<number, ShotMeta> {
const out: Record<number, ShotMeta> = {};
shots.forEach((s) => {
out[s.index] = {
role: s.role,
confidence: s.role_confidence,
garmentCount: s.garment_count ?? null,
sharpness: s.sharpness ?? null,
blurry: s.blurry ?? false,
};
});
return out;
}
async function runGrouping(list?: File[]) {
const shotFiles = list ?? files;
if (!shotFiles.length) return;
setBusy('grouping');
setError(null);
setSaveResult(null);
setSavedGroupIds(new Set());
try {
// Per-shot capture metadata (#535/#532): taken_at (real capture time —
// resurrects the 5-min time-gap split for EXIF-less canvas JPEGs) and
// burst_index (same-burst shots never pHash-split). Library picks have
// burstIndex null → only taken_at is sent, legacy pHash kept.
const takenAt = shotFiles.map((_, i) => shotsMetaRef.current[i]?.takenAt ?? null);
const burstIndex = shotFiles.map((_, i) => shotsMetaRef.current[i]?.burstIndex ?? null);
// Shrink before the upload (see shrinkShotForUpload) — a 30-shot pick is
// the same one-request payload as a rapid batch and breaks the same way.
const uploadFiles = await shrinkShotsForUpload(shotFiles);
const resp = await previewGarmentCapture(uploadFiles, 'library', { takenAt, burstIndex, singleGroup: oneGarment });
const aiGroups: LocalGroup[] = resp.groups.map((g: BulkPreviewGroup) => ({
id: g.group_index,
shots: g.shots.map((s) => s.index).sort((a, b) => a - b),
fields: { ...aiFields(g), designCity: detectedCity ?? DESIGN_CITY_DEFAULT },
aiError: g.ai_error ?? null,
aiTagsEmpty: Object.keys(g.ai_tags ?? {}).length === 0 && Object.keys(g.ai_tags_review ?? {}).length === 0,
aiTags: g.ai_tags ?? {},
aiReview: g.ai_tags_review ?? {},
duplicateMatches: g.duplicate_matches ?? [],
}));
const covered = new Set<number>();
const meta: Record<number, ShotMeta> = {};
resp.groups.forEach((g) => g.shots.forEach((s) => { covered.add(s.index); Object.assign(meta, collectShotMeta([s])); }));
const orphaned = shotFiles.map((_, i) => i).filter((i) => !covered.has(i));
setGroups(aiGroups);
setUnassigned(orphaned);
setShotMeta(meta);
setPreviewId(resp.preview_id);
setCaptureMode(rapidMode ? 'camera' : 'library');
// Tags panel starts CLOSED: AI pre-fills exist, but the user must still
// feed mandatory fields (category etc.) — don't bury the form behind an
// expanded tags panel.
setOpenTags(new Set());
setPhase('review');
setBusy(null);
} catch (err) {
const detail = (err as { response?: { data?: { detail?: { error?: string; code?: string; rejected_files?: string[]; rejected_indices?: number[] } } } })?.response?.data?.detail;
// Garment-only validation (422 NOT_A_GARMENT): name the rejected shots
// AND highlight the offending thumbnails in red so the user can see
// exactly which photo to drop.
if (detail?.code === 'NOT_A_GARMENT') {
const rejectedNames = new Set(detail.rejected_files ?? []);
if (rejectedNames.size) {
// rejected_files are the server-side temp names; map back to the
// client's original files by upload order (server preserves order,
// so rejected_indices ↔ file positions in the upload call).
setRejectedIdx(new Set(detail.rejected_indices ?? []));
}
setError(`${detail.error} Remove the highlighted photo${(detail.rejected_indices?.length ?? 0) > 1 ? 's' : ''} and try again.`);
setBusy(null);
return;
}
setError(apiError(err, err instanceof Error ? err.message : 'Could not analyse your shots. Please try again.'));
setBusy(null);
}
}
// ── Group editing (local only; backend clusters once) ─────────────────────
// HG-F4 (#514): group mutations during an in-flight save corrupt the saved
// group-id mapping and stale indices — block edits while saving.
function patchGroup(id: number, fn: (g: LocalGroup) => LocalGroup) {
if (busy === 'saving') return;
setGroups((p) => p.map((g) => (g.id === id ? fn(g) : g)));
}
function toggleGroupField(id: number, field: keyof GroupFields, value: string) {
patchGroup(id, (g) => ({ ...g, fields: { ...g.fields, [field]: value } }));
}
function toggleTag(id: number, key: string, value: string) {
patchGroup(id, (g) => ({ ...g, fields: { ...g.fields, tags: { ...g.fields.tags, [key]: value } } }));
}
// Structured palette edit for one group. Serialised (validated) on save.
function setGroupPalette(id: number, palette: ColourEntry[]) {
patchGroup(id, (g) => ({ ...g, fields: { ...g.fields, palette } }));
}
function setShotRole(idx: number, role: string) {
setShotMeta((p) => ({ ...p, [idx]: { ...p[idx], role } }));
}
function removeShotFromGroup(gid: number, idx: number) {
patchGroup(gid, (g) => ({ ...g, shots: g.shots.filter((s) => s !== idx) }));
setUnassigned((p) => [...p, idx].sort((a, b) => a - b));
}
function mergeSelected() {
if (busy === 'saving') return; // HG-F4 #514
const targets = groups.filter((g) => selGroups.has(g.id)).sort((a, b) => a.id - b.id);
if (targets.length < 2) return;
const [keep, ...rest] = targets;
const mergedShots = [...new Set(targets.flatMap((g) => g.shots))].sort((a, b) => a - b);
const keepIds = new Set(rest.map((g) => g.id));
setGroups((p) =>
p
.filter((g) => !keepIds.has(g.id))
.map((g) => (g.id === keep.id ? { ...g, shots: mergedShots } : g)),
);
setSelGroups((p) => {
const n = new Set(p);
rest.forEach((g) => n.delete(g.id));
return n;
});
toast.success(`Merged ${targets.length} garments into one`);
}
function createGroupFromUnassigned() {
const chosen = unassigned.filter((i) => selUnassigned.has(i)).sort((a, b) => a - b);
if (!chosen.length) return;
const gid = nextId.current++;
const g: LocalGroup = { id: gid, shots: chosen, fields: emptyFields(), duplicateMatches: [] };
setGroups((p) => [...p, g]);
setUnassigned((p) => p.filter((i) => !selUnassigned.has(i)));
setSelUnassigned(new Set());
setOpenTags((p) => new Set(p).add(gid));
}
function dropGroup(gid: number) {
if (busy === 'saving') return; // HG-F4 #514
const g = groups.find((x) => x.id === gid);
if (!g) return;
setUnassigned((p) => [...p, ...g.shots].sort((a, b) => a - b));
setGroups((p) => p.filter((x) => x.id !== gid));
setSelGroups((p) => { const n = new Set(p); n.delete(gid); return n; });
}
// ── Save ───────────────────────────────────────────────────────────────────
function buildFieldsPayload(
fields: GroupFields,
aiTags: Record<string, { value?: unknown; confidence?: number }> = {},
aiReview: Record<string, { value?: unknown; confidence?: number }> = {},
): Record<string, unknown> {
const tags: Record<string, unknown> = {};
// #689: keep the model's attribution for every value the reviewer did not
// type over — persisted as human/100% a capture-born garment's AI readings
// looked like human verifications in the review UI.
// `confidence` is optional: an AI value whose confidence the API did not
// deliver as a number is still attributed to the model, and the server then
// applies its own conservative default (0.0 → label "low").
const tagsMeta: Record<string, { source: string; confidence?: number }> = {};
Object.entries(fields.tags).forEach(([k, v]) => {
if (!v || !v.trim()) return;
const value = v.trim();
tags[k] = value;
const ai = aiTags[k] ?? aiReview[k];
if (!ai) return;
const aiText = aiValueText((ai as { value?: unknown }).value ?? ai);
// An AI value whose confidence did not arrive as a number still belongs to
// the model: send source-only and let the server's default (0.0 → "low")
// apply, instead of falling through to a human 100% verification.
if (aiText.trim() === value) {
tagsMeta[k] = typeof (ai as { confidence?: unknown }).confidence === 'number'
? { source: 'ai', confidence: (ai as { confidence: number }).confidence }
: { source: 'ai' };
}
});
// The colour palette is saved as the validated array itself — the flattened
// "Off White, Black" string used to destroy every hex (#652). Omitted when
// the reviewer removed every row / no colour was detected.
const palette = serializeColour(fields.palette);
if (palette.length > 0) {
tags.colour = palette;
// The palette is a model reading (prefilled from the tagger, hexes merged
// server-side), so it carries the model's attribution while the reviewer
// leaves it alone — otherwise the review UI shows "Human 100%" for it.
const colourEntry = (aiTags['colour'] ?? aiReview['colour']) as
{ value?: unknown; confidence?: number } | undefined;
const aiPalette = colourEntry
? serializeColour(parseColourValue((colourEntry as { value?: unknown }).value ?? colourEntry))
: [];
if (aiPalette.length > 0 && JSON.stringify(aiPalette) === JSON.stringify(palette)) {
tagsMeta.colour = typeof colourEntry?.confidence === 'number'
? { source: 'ai', confidence: colourEntry.confidence }
: { source: 'ai' };
}
}
const out: Record<string, unknown> = { segment: fields.segment, category: fields.category };
if (fields.season && fields.season.trim()) out.season = fields.season.trim();
if (fields.collection && fields.collection.trim()) out.collection_name = fields.collection.trim();
if (fields.description && fields.description.trim()) out.description = fields.description.trim();
if (fields.fabricComposition.trim()) out.fabric_composition = fields.fabricComposition.trim();
if (fields.fabricQuality.trim()) out.fabric_quality = fields.fabricQuality.trim();
if (fields.patternNumber.trim()) out.pattern_number = fields.patternNumber.trim();
if (fields.counters.trim()) out.counters = fields.counters.trim();
if (fields.designCity && fields.designCity !== 'unknown') out.design_team_city = fields.designCity;
out.is_co_ord_set = fields.isCoord;
out.tags = tags;
if (Object.keys(tagsMeta).length > 0) out.tags_meta = tagsMeta;
return out;
}
// `force` retries a save the server rejected as an image duplicate (#530) —
// only ever set from the duplicate override dialog.
async function handleSave(force = false) {
const invalid = groups.filter((g) => !g.fields.category).map((g) => g.id);
if (invalid.length) {
setMissingCat(new Set(invalid));
toast.error(`Set a category for ${invalid.length} garment${invalid.length > 1 ? 's' : ''} before saving`);
return;
}
// Shots left in the unassigned tray would be silently dropped — block the
// save and tell the user (hermaguard HG-11).
if (unassigned.length > 0) {
toast.error(`${unassigned.length} shot${unassigned.length !== 1 ? 's' : ''} still unassigned — group or remove ${unassigned.length === 1 ? 'it' : 'them'} before saving`);
return;
}
setMissingCat(new Set());
if (!previewId) return;
setBusy('saving');
setError(null);
setSaveResult(null);
setSavedGroupIds(new Set());
setDupConflict(null);
// Keep groups in stable array order — `failed[].group_index` maps to it.
// HG-F1 (#514): already-saved groups must NOT be re-sent on a partial-
// save retry — their temp shots were renamed server-side, so re-sending
// re-fails them with FILE_MISSING forever. Track saved group ids and
// exclude them (mapped through the stable array order at send time).
const ordered = groups.slice();
const savedIds = new Set(savedGroupIds);
const pending = ordered
.map((g, i) => ({ g, i }))
.filter(({ g }) => !savedIds.has(g.id));
if (pending.length === 0) {
setBusy(null);
toast.error('All garments in this batch are already saved.');
return;
}
try {
const resp = await saveGarmentCapture({
preview_id: previewId,
groups: pending.map(({ g }) => ({ shots: g.shots, fields: buildFieldsPayload(g.fields, g.aiTags, g.aiReview) })),
remove_bg: removeBg || undefined,
force: force || undefined,
});
setSaveResult(resp);
// #843: the sent index → local group id mapping, for the duplicate list
// the success panel renders (see sentGroupIds above).
setSentGroupIds(pending.map(({ g }) => g.id));
// Map failed[].group_index (index INTO the pending payload) back to the
// local group id so the UI names the right garment.
const failedLocal = resp.failed.map((f) => ({ ...f, group_index: pending[f.group_index]?.i ?? f.group_index }));
setSavedGroupIds((p) => new Set([...p, ...resp.saved.map((s) => {
const sent = pending[resp.saved.indexOf(s)];
return sent ? sent.g.id : -1;
})].filter((x) => x >= 0)));
// Remember the design city used for the next session (auto-detect tier 1).
const usedCity = ordered[0]?.fields.designCity;
if (usedCity) rememberCity(usedCity);
resp.saved.forEach((s) => toast.success(`${s.style_number} saved to library`));
if (resp.status === 'ok') toast.success(`Saved ${resp.saved.length} garment${resp.saved.length !== 1 ? 's' : ''}`);
else if (failedLocal.length) toast.error(`${failedLocal.length} garment${failedLocal.length !== 1 ? 's' : ''} could not be saved — your shots are kept, fix the group and save again`);
// Rewrite the reported failures' indices for the partial panel.
if (failedLocal.length) (resp as BulkSaveResponse).failed = failedLocal;
} catch (err) {
// Issue #530: image-duplicate hard gate. The server pre-checks every group
// BEFORE persisting any, so nothing was saved — offer the explicit
// "this is a different garment" override instead of a dead end.
const dup = parseImageDuplicateDetail(err);
if (dup) {
const localId = typeof dup.failed_group_index === 'number'
? pending[dup.failed_group_index]?.g.id ?? null
: null;
setError(dup.error);
setDupConflict({
message: dup.error,
styleNumber: dup.style_number,
distance: dup.phash_distance,
groupId: localId,
});
toast.error(`Duplicate photo — already saved as ${dup.style_number}`);
return;
}
const detail = (err as { response?: { data?: { detail?: { error?: string; code?: string } } } })?.response?.data?.detail;
// Preview expired server-side (60-min TTL) — send the user back to pick
// photos with a clear message instead of a dead end (hermaguard HG-11).
if (detail?.code === 'PREVIEW_NOT_FOUND') {
setError('This review session expired. Please pick your photos again.');
setPhase('select');
setGroups([]);
setUnassigned([]);
setShotMeta({});
setPreviewId(null);
setSaveResult(null);
setSavedGroupIds(new Set());
return;
}
setError(apiError(err, err instanceof Error ? err.message : 'Save failed. Please try again.'));
} finally {
setBusy(null);
}
}
const groupedCount = groups.length;
const totalShots = groups.reduce((n, g) => n + g.shots.length, 0) + unassigned.length;
const canSave = phase === 'review' && groups.length > 0 && busy === null;
// #843: the duplicates the SAVE itself reported. On a successful save the
// review cards are gone, so this list lives in the success panel. A group
// whose group_index cannot be mapped back to a local group still gets a line
// — the matched style number is the useful part, and silently dropping the
// row would hide the duplicate the save just told us about.
const savedDuplicates = (saveResult?.possible_duplicates ?? [])
.filter((entry) => entry.matches.length > 0)
.map((entry) => {
const localId = sentGroupIds[entry.group_index];
return {
key: `${entry.group_index}-${entry.matches[0].garment_id}`,
label: localId === undefined ? null : `Garment ${localId + 1}`,
matches: entry.matches,
};
});
const visibleJobs = queuedJobs.filter((r) => !dismissedJobs.has(r.job_token));
// refs #929 — only a zero-touch (auto_save) capture targets the Review queue;
// the classic background job's garments are already human-verified.
const zeroTouch = reconcile?.zeroTouch === true;
return (
// TopNav is `fixed top-0 h-16`, so every page must clear 64px on its own.
// pages/Upload.tsx used to supply this wrapper; #688 made bulk capture the
// page itself and dropped it, so the PageHeader rendered UNDER the nav
// (measured on uat: nav bottom 64px vs H1 top 44px). Same wrapper as the
// parked single flow, so both modes look identical.
<div className="min-h-screen bg-paper text-ink font-archivo pt-16">
<main className="max-w-[1100px] mx-auto px-5 sm:px-8 pb-40 lg:pb-20">
<PageHeader
eyebrow="BULK CAPTURE"
title="Upload garments in bulk"
subtitle="Pick up to 50 shots at once — the AI groups them into garments for review. Merge or split groups, tag each garment, then save them all to the library in one batch."
/>
{/* Background-job progress chips (issue #523) — app-native control row,
dismissible. Live while the tab is open; the job keeps running. */}
{visibleJobs.length > 0 && (
<div data-testid="rapid-job-chips" className="flex flex-wrap items-center gap-3 mb-5 anim-fade-up">
{visibleJobs.map((rec) => (
<div key={rec.job_token} data-testid="rapid-job-chip" className="flex items-center gap-2 h-8 pl-3 pr-1.5 bg-ivory border border-line rounded shadow-card">
<button
type="button"
data-testid="rapid-job-chip-open"
aria-label="Show live capture status"
onClick={() => setSheetJob(rec)}
className="flex items-center gap-2 h-full flex-1 min-w-0 cursor-pointer"
>
<span className={'w-1.5 h-1.5 rounded-full shrink-0 ' + (rec.status === 'failed' ? 'bg-[#9a2b22]' : isCaptureJobTerminal(rec.status) ? 'bg-[#3f7d4e]' : 'bg-ink animate-pulse')} />
<span data-testid="rapid-job-chip-status" className="font-plex text-[10px] uppercase tracking-[0.12em] text-ink">
{jobStatusLabel(rec.status)}{rec.group_count ? ` ${rec.group_count}` : ''}
</span>
<span className="font-plex text-[10px] text-muted hidden sm:inline">· background</span>
</button>
{/* A failed send never retried by itself (any explained 4xx is
terminal) — give the shots a way back without re-shooting. */}
{rec.status === 'failed' && rec.kind === 'pending' && (
<button
type="button"
data-testid="rapid-job-chip-retry"
aria-label="Send this capture again"
onClick={() => { void retryCaptureJob(rec.job_token); }}
className="h-6 px-2 border border-line text-[10px] uppercase tracking-[0.1em] text-ink rounded hover:border-ink/40 transition-colors"
>
Retry
</button>
)}
<button
type="button"
aria-label="Dismiss progress chip"
data-testid="rapid-job-chip-dismiss"
onClick={() => setDismissedJobs((p) => new Set(p).add(rec.job_token))}
className="w-5 h-5 text-muted hover:text-ink text-[12px] leading-none"
>
×
</button>
</div>
))}
</div>
)}
{instantChip && (
<div data-testid="instant-capture-chip" className="flex flex-wrap items-center gap-3 mb-5 anim-fade-up">
<div className="flex items-center gap-2 h-8 pl-3 pr-1.5 bg-ivory border border-line rounded shadow-card">
<button
type="button"
aria-label="Show live capture status"
data-testid="instant-capture-chip-open"
onClick={() => { const rec = visibleJobs.find((j) => !isCaptureJobTerminal(j.status)) ?? visibleJobs[0]; if (rec) setSheetJob(rec); }}
className="flex items-center gap-2 h-full flex-1 min-w-0 cursor-pointer"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0 bg-ink animate-pulse" />
<span className="font-plex text-[10px] uppercase tracking-[0.12em] text-ink">
{instantChip.shots} shot{instantChip.shots === 1 ? '' : 's'} · grouping garments in background
</span>
</button>
<button
type="button"
aria-label="Dismiss background capture chip"
data-testid="instant-capture-chip-dismiss"
onClick={() => setInstantChip(null)}
className="w-5 h-5 text-muted hover:text-ink text-[12px] leading-none"
>
×
</button>
</div>
</div>
)}
{/* Web-push opt-in (issue #524): get pinged when a background capture
finishes, even after you've left the screen. */}
<div data-testid="push-notifications-row" className="flex flex-col items-start gap-2 mb-5 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-plex text-[10px] uppercase tracking-[0.12em] text-muted">
Get pinged when a background capture finishes
</span>
<PushNotificationsToggle />
</div>
{/* Review reconciliation (issue #523): a rapid capture finished while the
user was away. The worker already saved the garments — point at them. */}
{reconcile && (
<div data-testid="rapid-reconcile-banner" className="mb-6 p-5 border border-[#3f7d4e]/40 bg-ivory flex items-start gap-3 rounded shadow-card anim-fade-up">
<span className="w-2 h-2 rounded-full bg-[#3f7d4e] mt-1.5 shrink-0" />
<div className="flex flex-col gap-1 w-full">
<h3 className="font-display italic text-[16px] text-ink">
{reconcile.drafts
? `Capture saved: ${reconcile.ready} garment${reconcile.ready === 1 ? '' : 's'} added, ${reconcile.drafts} need review`
: `Rapid capture finished: ${reconcile.ready} garment${reconcile.ready === 1 ? '' : 's'} ready for review`}
</h3>
<p className="text-[12.5px] text-muted">
Saved to your library while you were away
{reconcile.drafts ? ` — ${reconcile.drafts} flagged for a quick review.` : '.'}
</p>
<div className="flex flex-wrap items-center gap-3 mt-1">
<Link to={captureResultsPath(zeroTouch)} data-testid="rapid-reconcile-review"
className="text-[12.5px] text-ink underline underline-offset-2 hover:opacity-70 w-fit">
{zeroTouch
? (reconcile.drafts
? `Review ${reconcile.drafts} draft${reconcile.drafts === 1 ? '' : 's'} →`
: 'Open the review queue →')
: 'Open the library →'}
</Link>
{zeroTouch && (
<Link to="/library" data-testid="rapid-reconcile-library"
className="text-[12px] text-muted underline underline-offset-2 hover:text-ink w-fit">
Open the library →
</Link>
)}
</div>
{/* #831: the advisory the Review Queue shows much later, said here —
the zero-touch path saves the garment without the user ever
watching the job sheet, so this is the first (and often only)
place the match is visible before review. Amber = the app's
advisory duplicate language; nothing is blocked. */}
{reconcile.duplicates ? (
<span data-testid="rapid-reconcile-duplicate"
className="mt-1.5 px-2 py-1 flex items-start gap-1.5 border border-[#9a7322]/40 bg-[#f7f1e2] rounded font-plex text-[11px] text-[#7c5b15] leading-snug w-fit">
<span className="w-1.5 h-1.5 rounded-full bg-[#9a7322] mt-1 shrink-0" />
<span>
<span className="font-medium">Possible duplicate of {reconcile.duplicateStyle ?? 'a garment already in the library'}</span>
{reconcile.duplicates > 1 ? ` and ${reconcile.duplicates - 1} more` : ''}
{' '}— check before you keep both.
</span>
</span>
) : null}
{reconcile.failed.length > 0 && (
<ul className="flex flex-col gap-1 text-[12.5px] mt-1.5">
{reconcile.failed.map((msg, i) => (
<li key={i} className="flex items-start gap-2 text-[#9a2b22]"><span>·</span><span>{msg}</span></li>
))}
</ul>
)}
<button type="button" data-testid="rapid-reconcile-dismiss" onClick={() => setReconcile(null)} className="self-start text-[11px] text-muted hover:text-ink transition-colors mt-1">
Dismiss
</button>
</div>
</div>
)}
{error && <div className="mb-5 px-4 py-3 border border-[#9a2b22]/30 bg-ivory text-[12.5px] text-[#9a2b22] anim-fade-up">{error}</div>}
{saveResult && saveResult.status === 'ok' && (
<div data-testid="bulk-save-ok" className="mb-6 p-5 border border-[#3f7d4e]/40 bg-ivory flex items-start gap-3 rounded shadow-card anim-fade-up">
<span className="w-2 h-2 rounded-full bg-[#3f7d4e] mt-1.5 shrink-0" />
<div className="flex flex-col gap-1">
<h3 className="font-display italic text-[16px] text-ink">All {saveResult.saved.length} garment{saveResult.saved.length !== 1 ? 's' : ''} saved</h3>
<p className="text-[12.5px] text-muted">{saveResult.saved.map((s) => s.style_number).join(', ')}</p>
{/* #843: the advisory the save itself returned — the review cards
that used to carry the preview's duplicate badge are gone by
now, so this is the only place left to say it. Amber = the
app's existing duplicate language, never blocking: the garments
are already saved. */}
{savedDuplicates.length > 0 && (
<ul data-testid="bulk-save-duplicates" className="flex flex-col gap-1.5 mt-1.5">
{savedDuplicates.map((row) => {
const top = row.matches[0];
const style = candidateLabel(top);
return (
<li
key={row.key}
data-testid={`bulk-save-duplicate-${row.key}`}
className="flex items-start gap-1.5 px-2 py-1 border border-[#9a7322]/40 bg-[#f7f1e2] rounded font-plex text-[9.5px] text-[#7c5b15] leading-snug"
>
<span className="w-1.5 h-1.5 rounded-full bg-[#9a7322] mt-1 shrink-0" />
<span>
{row.label && <span className="font-medium">{row.label}: </span>}
<span className="font-medium">Possible duplicate of {style}</span>
{moreMatches(row.matches.length)}
{top.reason ? ` ${reasonDetail(style, top.reason)}` : ''}
</span>
</li>
);
})}
</ul>
)}
{/* #688: per-garment QR tag printing straight from the saved panel. */}
<div className="flex flex-wrap gap-2 mt-1">
{saveResult.saved.map((s) => (
<button
key={s.garment_id}
type="button"
data-testid={`qr-saved-${s.style_number}`}
onClick={() => setQrGarment(s.garment_id)}
className={btnGhost}
>
Print QR tag
</button>
))}
</div>
<Link to="/library" className="text-[12.5px] text-ink underline underline-offset-2 hover:opacity-70 mt-1 w-fit">Open in library →</Link>
</div>
</div>
)}
{saveResult && saveResult.status === 'partial' && (
<div data-testid="bulk-save-partial" className="mb-6 p-5 border border-[#9a7322]/40 bg-ivory flex items-start gap-3 rounded shadow-card anim-fade-up">
<span className="w-2 h-2 rounded-full bg-[#9a7322] mt-1.5 shrink-0" />
<div className="flex flex-col gap-1.5 w-full">
<h3 className="font-display italic text-[16px] text-ink">Partially saved — {saveResult.saved.length} of {saveResult.saved.length + saveResult.failed.length} garments</h3>
<ul className="flex flex-col gap-1 text-[12.5px]">
{saveResult.failed.map((f) => {
const g = groups[f.group_index];
return (
<li key={f.group_index} className="flex items-start gap-2 text-[#9a2b22]">
<span>·</span>
<span>Garment {g ? g.id + 1 : f.group_index + 1}: {f.error}</span>
</li>
);
})}
</ul>
{/* #688: the garments that DID save still get per-garment QR tags. */}
{saveResult.saved.length > 0 && (
<div className="flex flex-wrap gap-2">
{saveResult.saved.map((s) => (
<button
key={s.garment_id}
type="button"
data-testid={`qr-saved-${s.style_number}`}
onClick={() => setQrGarment(s.garment_id)}
className={btnGhost}
>
Print QR tag
</button>
))}
</div>
)}
</div>
</div>
)}
{phase === 'select' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
{/* Pick shots — mobile: the OS chooser natively offers photo library /
take photo / choose files (iOS 3-option sheet). Desktop: the file
dialog has no camera, so a "Take photo" button opens an in-app
getUserMedia capture modal. */}
<div className="flex flex-col gap-4 anim-fade-up">
{/* Capture-source toggle: rapid camera panel vs plain picker.
Rapid capture = camera-first multi-shot for fast garment shoots;
picker = OS chooser (gallery/files, incl. Take Photo on mobile). */}
<div className="flex items-center gap-2 flex-wrap" data-testid="capture-mode-row">
<button
type="button"
data-testid="mode-rapid"
onClick={() => setRapidMode(true)}
className={modeChipCls + ' ' + (rapidMode ? 'bg-ink text-paper hover:bg-ink/90' : 'border border-line text-muted hover:text-ink hover:border-ink/30')}
>
◉ Rapid camera capture
</button>
<button
type="button"
data-testid="mode-picker"
onClick={() => { setRapidMode(false); if (instantMode) firePendingInstant(); }}
className={modeChipCls + ' ' + (!rapidMode ? 'bg-ink text-paper hover:bg-ink/90' : 'border border-line text-muted hover:text-ink hover:border-ink/30')}
>
Pick photos
</button>
{/* #688: `1 garment` — skip AI grouping; every shot is one item.
Default OFF, so the AI-grouping flow is unchanged. Hidden on the
rapid + Instant path: that hand-off posts an auto_save JOB (no
/preview call), so the assertion could not be honoured — showing
it there would be a silent no-op. */}
{!(rapidMode && instantMode) && (
<button
type="button"
data-testid="mode-one-garment"
aria-pressed={oneGarment}
onClick={() => setOneGarment((v) => !v)}
title="Treat every shot as one garment — skip AI grouping"
className={modeChipCls + ' ' + (oneGarment ? 'bg-ink text-paper hover:bg-ink/90' : 'border border-line text-muted hover:text-ink hover:border-ink/30')}
>
1 garment
</button>
)}
{/* Zero-touch (#555): rapid-camera shots auto-hand off to the
background worker — no review step. Additive: turning it OFF
restores the classic review-then-save flow. */}
<button
type="button"
data-testid="mode-instant"
aria-pressed={instantMode}
onClick={() => setInstantMode((v) => { if (v) { clearInstantTimer(); latestRapidShots.current = []; } return !v; })}
title="Rapid camera shots go straight to the background — no review step"
className={modeChipCls + ' ' + (instantMode ? 'bg-ink text-paper hover:bg-ink/90' : 'border border-line text-muted hover:text-ink hover:border-ink/30')}
>
⇢ Instant background
</button>
</div>
{rapidMode ? (
<RapidCapturePanel
maxShots={MAX_FILES}
onShotsChange={scheduleInstantHandoff}
doneLabel={instantMode
? `Done — send ${instantPendingCount} shot${instantPendingCount === 1 ? '' : 's'} to background`
: undefined}
onDone={(shots) => {
setRapidMode(false);
// `shots` carry per-shot takenAt + burstIndex (#535/#532);
// addFiles stores them index-aligned so runGrouping can send
// them to the backend grouping.
const shotFiles = shots.map((s) => s.file);
const combined = [...files, ...shotFiles];
const metas = shots.map((s) => ({ takenAt: s.takenAt, burstIndex: s.burstIndex }));
if (instantMode) {
// Zero-touch (#555): hand straight to the background worker
// — no review button, no wait.
void handoffInstant(shotFiles, metas);
return;
}
addFiles(shotFiles, metas);
// Feed straight into AI grouping — rapid capture's whole point
// is capture-many-then-review (state + list passed explicitly).
void runGrouping(combined);
}}
hint={instantMode
? 'Shoot each garment back-to-back. When you are done, tap Done — shots go to the background (AI groups + saves) and you can close the app; we will notify you.'
: 'Shoot each garment back-to-back — front, back, details. AI groups the shots into garments automatically after Done.'}
/>
) : (
<>
<div
className="border border-dashed border-line bg-ivory rounded shadow-card p-6 flex flex-col items-center justify-center gap-3 text-center cursor-pointer hover:border-ink/40 hover:bg-[#f0ece4] transition-colors"
onClick={() => fileInputRef.current?.click()}
data-testid="bulk-pick-area"
>
<div className="w-9 h-9 border border-line flex items-center justify-center text-muted"><svg width="15" height="15" viewBox="0 0 14 14" fill="none"><path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.3" /></svg></div>
<div>
<p className="font-plex text-[10px] uppercase tracking-[0.14em] text-ink/70">Add up to {MAX_FILES} photos</p>
{/* One hint, two arrangements: phones stack the three parts so the
list never wraps mid-separator (an orphaned "· max 10 MB each"
read as a typo); sm+ reproduces acceptLabel on a single line. */}
<p className="font-plex text-[10px] leading-relaxed text-muted mt-1 flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<span>Camera or gallery</span>
<span className="hidden sm:inline">{' · '}</span>
<span>{CAPTURE_POLICY.extensionsLabel}</span>
<span className="hidden sm:inline">{' · '}</span>
<span className="whitespace-nowrap">{CAPTURE_POLICY.sizeLabel}</span>
</p>
</div>
<input
ref={fileInputRef}
data-testid="bulk-file-input"
type="file"
accept={CAPTURE_POLICY.accept}
multiple
className="hidden"
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }}
/>
</div>
<button
type="button"
data-testid="bulk-open-camera"
onClick={(e) => { e.stopPropagation(); setCameraOpen(true); }}
className={btnGhost + ' self-start hidden coarser:pointer-events-none [@media(pointer:fine)]:flex'}
>
◉ Take photo with camera
</button>
<CameraCaptureModal
open={cameraOpen}
onClose={() => setCameraOpen(false)}
onCapture={(blob) => {
const f = new File([blob], `camera_${Date.now()}_${cameraShots + 1}.jpg`, { type: 'image/jpeg' });
setCameraShots((n) => n + 1);
addFiles([f]);
}}
shotsTaken={cameraShots}
/>
</>
)}
{busy === 'grouping' && (
<div className="p-4 border border-line bg-ivory flex items-center gap-3 rounded shadow-card">
<UploadLottie size={44} className="shrink-0" />
<div>
<p className="text-[12.5px] font-medium text-ink">Reading shots, grouping garments…</p>
<p className="font-plex text-[10px] text-muted mt-0.5">Classifying shot roles + clustering {files.length} shot{files.length !== 1 ? 's' : ''}</p>
</div>
</div>
)}
{files.length > 0 && (
<>
<div className="flex flex-wrap gap-2">
{urls.map((u, i) => (
<div key={i} className={`relative w-16 h-[84px] overflow-hidden group ${rejectedIdx.has(i) ? 'ring-2 ring-[#9a2b22] border border-[#9a2b22]' : 'border border-line'}`}>
<img src={u} alt={`Shot ${i + 1}`} className={`w-full h-full object-cover ${rejectedIdx.has(i) ? 'opacity-60' : ''}`} />
{rejectedIdx.has(i) && (
<span className="absolute inset-x-0 top-0 h-[14px] flex items-center justify-center bg-[#9a2b22] text-paper font-plex text-[7px] uppercase tracking-wide">Not garment</span>
)}
<button
type="button"
aria-label={`Remove shot ${i + 1}`}
onClick={() => removeFile(i)}
className="absolute top-[15px] right-0.5 w-5 h-5 bg-ink/70 text-paper text-[10px] leading-none flex items-center justify-center hover:bg-[#9a2b22]"
>
×
</button>
<span className="absolute bottom-0 left-0 px-1 h-[16px] flex items-center bg-ink/70 text-paper font-plex text-[8px]">{i + 1}</span>
</div>
))}
</div>
<div className="flex items-center justify-between px-1">
<span className="font-plex text-[10px] text-muted">{files.length}/{MAX_FILES} shots</span>
<button type="button" onClick={clearFiles} className={btnGhost}>Clear all</button>
</div>
</>
)}
</div>
{/* How it works */}
<div className="border border-line bg-ivory rounded shadow-card anim-fade-up p-6 flex flex-col gap-4" style={{ animationDelay: '90ms' }}>
<p className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">How bulk capture works</p>
<ol className="flex flex-col gap-3 text-[12.5px] text-ink leading-relaxed list-none">
<li className="flex gap-3"><span className="w-5 h-5 shrink-0 rounded-full bg-paper border border-line flex items-center justify-center font-plex text-[10px]">1</span><span><span className="font-medium">Pick your shots</span> — up to 50 from the camera or file manager.</span></li>
<li className="flex gap-3"><span className="w-5 h-5 shrink-0 rounded-full bg-paper border border-line flex items-center justify-center font-plex text-[10px]">2</span><span><span className="font-medium">AI groups them</span> — shots that look like the same garment are clustered together with role labels; shots repeating a garment already in the library get a duplicate flag.</span></li>
<li className="flex gap-3"><span className="w-5 h-5 shrink-0 rounded-full bg-paper border border-line flex items-center justify-center font-plex text-[10px]">3</span><span><span className="font-medium">Review &amp; tidy</span> — merge or split groups, tag each garment.</span></li>
<li className="flex gap-3"><span className="w-5 h-5 shrink-0 rounded-full bg-paper border border-line flex items-center justify-center font-plex text-[10px]">4</span><span><span className="font-medium">Save all</span> — each group becomes a library garment with its own auto style number.</span></li>
</ol>
{/* Optional background removal — user's call, default OFF.
Applies to each group's hero (front) shot at save time. */}
<label className="flex items-center gap-3 cursor-pointer select-none -mt-1" data-testid="remove-bg-row">
<input
type="checkbox"
data-testid="remove-bg-toggle"
checked={removeBg}
onChange={(e) => setRemoveBg(e.target.checked)}
className="w-4 h-4 accent-[#1b1813]"
/>
<span className="text-[12px] text-ink">
Remove background on the <span className="font-medium">front shot</span> of each garment
<span className="text-muted"> · optional, default off</span>
</span>
</label>
<div className="mt-auto pt-2 flex justify-end">
<button
type="button"
data-testid="run-ai-grouping"
onClick={() => void runGrouping()}
disabled={files.length === 0 || busy === 'grouping'}
className={btnPrimary}
>
{busy === 'grouping' ? 'Analysing…' : `Review ${files.length} shot${files.length !== 1 ? 's' : ''} → group garments`}
</button>
</div>
</div>
</div>
) : (
/* ── Review groups ─────────────────────────────────────────────────── */
<div className="flex flex-col gap-5 anim-fade-up">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 justify-between border border-line bg-ivory rounded shadow-card p-4">
<div>
<p className="text-[13px] text-ink font-medium">{groupedCount} garment{groupedCount !== 1 ? 's' : ''} · {totalShots} shot{totalShots !== 1 ? 's' : ''}</p>
<p className="font-plex text-[10px] text-muted mt-0.5">Edit roles, move shots to Unassigned, merge or split groups before saving.</p>
</div>
<div className="flex items-center gap-2">
<button type="button" data-testid="merge-selected" onClick={mergeSelected} disabled={selGroups.size < 2} className={btnPrimary + ' disabled:opacity-30'}>
Merge selected ({selGroups.size})
</button>
<button type="button" onClick={() => { setPhase('select'); setGroups([]); setUnassigned([]); setShotMeta({}); setSaveResult(null);
setSavedGroupIds(new Set()); setPreviewId(null); setOneGarment(false); }} className={btnGhost}>Choose different photos</button>
</div>
</div>
{/* Group cards */}
<div className="flex flex-col gap-5">
{groups.map((g) => {
const missCat = missingCat.has(g.id);
const tagsOpen = openTags.has(g.id);
const filledTags = Object.values(g.fields.tags).filter((v) => v.trim()).length
+ g.fields.palette.filter((c) => c.colour_name.trim()).length;
// Only TAG_FIELDS render inputs here; the rest are corrected in the
// review form, so the header must not imply they are editable now.
const hiddenTags = Object.entries(g.fields.tags)
.filter(([k, v]) => v.trim() && !TAG_FIELDS.some((f) => f.key === k)).length;
// #831: the server's own duplicate sentence is safe to render
// verbatim and names the signals that actually matched (photo
// distance / description similarity); fall back to the wording
// used before the field existed.
const dupTop = g.duplicateMatches[0];
return (
<div
key={g.id}
data-testid={`bk-group-${g.id}`}
className={'border bg-ivory rounded shadow-card overflow-hidden anim-fade-up ' + (missCat ? 'border-[#9a2b22]/50' : 'border-line')}
>
{/* AI tagging failure banner — tell the user WHY tags are empty */}
{g.aiError && (
<div data-testid={`ai-error-${g.id}`} className="mx-5 mt-3 border border-[#b45309]/40 bg-[#fef3c7] rounded px-3.5 py-2.5">
<p className="font-plex text-[11px] text-[#92400e] leading-snug">
<span className="font-medium uppercase tracking-wide">AI tags unavailable</span> — {g.aiError.message}
</p>
</div>
)}
{/* F5 (#514): all AI tags skipped (malformed model fields were
dropped server-side, not an error) — no banner fires, so
without this hint the empty fields look like a bug. */}
{!g.aiError && g.aiTagsEmpty && (
<div data-testid={`ai-empty-${g.id}`} className="mx-5 mt-3 border border-line bg-white/60 rounded px-3.5 py-2.5">
<p className="font-plex text-[11px] text-muted leading-snug">
AI couldn't read any fields confidently for this garment — fill them manually below.
</p>
</div>
)}
{/* Issue #530: advisory image-duplicate badge. Amber, never
blocking — the user can compare the two photos, then
save anyway (the hard 409 gate offers the same override
at save time). */}
{g.duplicateMatches.length > 0 && (
<div data-testid={`dup-badge-${g.id}`} className="mx-5 mt-3 border border-[#9a7322]/40 bg-[#f7f1e2] rounded px-3.5 py-2.5 flex flex-col gap-3">
<div className="flex items-start gap-2.5">
<span className="w-2 h-2 rounded-full bg-[#9a7322] mt-1.5 shrink-0" />
<p className="font-plex text-[11px] text-[#7c5b15] leading-snug">
<span className="font-medium uppercase tracking-wide">Possible duplicate</span> — {dupTop?.reason
? <>{dupTop.reason}{g.duplicateMatches.length > 1 ? ` and ${g.duplicateMatches.length - 1} more` : ''}</>
: <>this photo closely matches <span className="font-medium">{dupTop?.style_number}</span>{g.duplicateMatches.length > 1 ? ` and ${g.duplicateMatches.length - 1} more` : ''} already in the library.</>}
</p>
<button
type="button"
data-testid={`dup-toggle-${g.id}`}
onClick={() => setDupOpen((p) => { const n = new Set(p); if (n.has(g.id)) n.delete(g.id); else n.add(g.id); return n; })}
className="ml-auto shrink-0 font-plex text-[10px] uppercase tracking-[0.12em] text-[#7c5b15] underline underline-offset-2 hover:opacity-70 transition-opacity"
>
{dupOpen.has(g.id) ? 'Hide' : 'Compare'}
</button>
</div>
{dupOpen.has(g.id) && (
<div className="flex flex-wrap gap-5 pl-[18px]">
{g.duplicateMatches.map((m) => (
<DuplicateCompare key={m.garment_id} localUrl={urls[g.shots[0]]} match={m} />
))}
</div>
)}
</div>
)}
{/* header */}
<div className="px-5 py-3.5 border-b border-line flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" data-testid={`select-group-${g.id}`} checked={selGroups.has(g.id)} onChange={() => setSelGroups((p) => { const n = new Set(p); if (n.has(g.id)) n.delete(g.id); else n.add(g.id); return n; })} className="accent-ink" />
<span className="font-plex text-[9px] uppercase tracking-[0.18em] text-muted">merge</span>
</label>
<div className="flex flex-col">
<h3 className="font-display text-[17px] text-ink leading-none">Garment {g.id + 1}</h3>
<span className="font-plex text-[9px] text-muted mt-1">{g.shots.length} shot{g.shots.length !== 1 ? 's' : ''}</span>
</div>
<button type="button" onClick={() => dropGroup(g.id)} className="ml-auto text-[11px] text-muted hover:text-[#9a2b22] transition-colors" data-testid={`drop-group-${g.id}`}>Remove group</button>
</div>
{missCat && <div className="px-5 py-2 bg-[#9a2b22]/10 font-plex text-[10px] text-[#9a2b22]">Category is required to auto-number this style.</div>}
{/* shots */}
<div className="px-5 py-4 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{g.shots.map((idx) => {
const meta = shotMeta[idx];
return (
<div key={idx} data-testid={`shot-card-${g.id}-${idx}`} className="flex items-center gap-3 border border-line bg-paper p-2.5">
{urls[idx] ? <img src={urls[idx]} alt={`Shot ${idx + 1}`} className="w-14 h-[72px] object-cover border border-line shrink-0" /> : <div className="w-14 h-[72px] border border-line shrink-0 bg-ivory" />}
<div className="flex flex-col items-start gap-1.5 min-w-0">
<span className="font-plex text-[8.5px] uppercase tracking-wide text-muted">Shot {idx + 1}</span>
<div data-testid={`role-${g.id}-${idx}`}>
<Dropdown
value={meta?.role ?? 'unknown'}
onChange={(v) => setShotRole(idx, v)}
options={SHOT_ROLE_OPTIONS}
buttonClassName="h-8 text-[11px]"
/>
</div>
{meta !== undefined && (
<span className="font-plex text-[9px] text-muted/70" data-testid={`conf-${g.id}-${idx}`}>confidence {meta.confidence}%</span>
)}
{/* Issue #534 Path A: advisory capture-quality badges.
Never blocking — the server doesn't reject on these,
and the user can save a blurry/multi shot as-is. */}
{meta?.blurry && (
<button
type="button"
data-testid={`blur-badge-${g.id}-${idx}`}
onClick={() => removeShotFromGroup(g.id, idx)}
title="Tap to remove this shot and retake it"
className="font-plex text-[8.5px] uppercase tracking-wide text-[#7c5b15] bg-[#f7f1e2] border border-[#9a7322]/40 rounded px-1.5 py-0.5 hover:border-[#9a7322]/70"
>
Low clarity · retake (tap to remove)
</button>
)}
{(meta?.garmentCount ?? 0) > 1 && (
<span
data-testid={`multi-badge-${g.id}-${idx}`}
className="font-plex text-[8.5px] uppercase tracking-wide text-[#7c5b15] bg-[#f7f1e2] border border-[#9a7322]/40 rounded px-1.5 py-0.5"
>
Multiple garments — shoot separately
</span>
)}
<button type="button" data-testid={`remove-shot-${g.id}-${idx}`} onClick={() => removeShotFromGroup(g.id, idx)} className="font-plex text-[9px] text-muted hover:text-[#9a2b22] transition-colors">Move to unassigned</button>
</div>
</div>
);
})}
</div>
{/* per-garment fields */}
<div className="px-5 py-4 border-t border-line flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Segment</label>
<Dropdown
value={g.fields.segment}
onChange={(v) => toggleGroupField(g.id, 'segment', v)}
options={[{ value: 'new_dev', label: 'New development' }, { value: 'shopping', label: 'Shopping sample' }]}
buttonClassName="w-full h-10 px-3 bg-paper border border-line text-[13px] shadow-none"
/>
</div>
<div>
<label className={labelCls}>Category <span className="text-[#9a2b22] normal-case tracking-normal">· required</span></label>
<div data-testid={`bk-cat-${g.id}`}>
<Dropdown
value={g.fields.category}
onChange={(v) => toggleGroupField(g.id, 'category', v)}
options={CATEGORY_OPTIONS}
placeholder="Select category"
buttonClassName={'w-full h-10 px-3 bg-paper border text-[13px] shadow-none ' + (missCat ? 'border-[#9a2b22]/50' : 'border-line')}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Season</label>
<Dropdown
value={g.fields.season}
onChange={(v) => toggleGroupField(g.id, 'season', v)}
options={['AW26', 'SS26', 'AW25', 'SS25'].map((s) => ({ value: s, label: s }))}
buttonClassName="w-full h-10 px-3 bg-paper border border-line text-[13px] shadow-none"
/>
</div>
<div>
<label className={labelCls}>Design city</label>
<Dropdown
value={g.fields.designCity}
onChange={(v) => toggleGroupField(g.id, 'designCity', v)}
options={['unknown', 'mumbai', 'bangalore', 'spain', 'delhi', 'chennai', 'kolkata'].map((c) => ({ value: c, label: c === 'unknown' ? 'Unknown' : c }))}
buttonClassName="w-full h-10 px-3 bg-paper border border-line text-[13px] shadow-none"
/>
</div>
</div>
<div>
<label className={labelCls}>Collection</label>
<input className={inputCls} value={g.fields.collection} onChange={(e) => toggleGroupField(g.id, 'collection', e.target.value)} data-testid={`bk-coll-${g.id}`} />
</div>
<div>
<label className={labelCls}>Description <span className="text-muted normal-case tracking-normal">· optional</span></label>
<textarea className={textareaCls} rows={2} value={g.fields.description} onChange={(e) => toggleGroupField(g.id, 'description', e.target.value)} placeholder="Construction notes, value additions…" data-testid={`bk-desc-${g.id}`} />
</div>
{/* metadata grid — same fields the single-upload flow collects */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Fabric</label>
<input className={inputCls} placeholder="82% Rayon 18% Nylon" value={g.fields.fabricComposition} onChange={(e) => toggleGroupField(g.id, 'fabricComposition', e.target.value)} data-testid={`bk-fabric-${g.id}`} />
</div>
<div>
<label className={labelCls}>Fabric quality <QrMark missing={qrMissingFor(g.fields)} field="fabric_quality" /></label>
<input className={inputCls} placeholder="Cotton Poplin 120gsm" value={g.fields.fabricQuality} onChange={(e) => toggleGroupField(g.id, 'fabricQuality', e.target.value)} data-testid={`bk-fabq-${g.id}`} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Pattern no. <span className="normal-case tracking-normal text-muted/70">· optional</span></label>
<input className={inputCls} placeholder="PTN-4471" value={g.fields.patternNumber} onChange={(e) => toggleGroupField(g.id, 'patternNumber', e.target.value)} data-testid={`bk-pattern-${g.id}`} />
</div>
<div>
<label className={labelCls}>Counters <span className="normal-case tracking-normal text-muted/70">· pieces</span> <QrMark missing={qrMissingFor(g.fields)} field="counters" /></label>
<input type="number" min="0" className={inputCls} placeholder="3" value={g.fields.counters} onChange={(e) => toggleGroupField(g.id, 'counters', e.target.value)} data-testid={`bk-counters-${g.id}`} />
</div>
</div>
{/* Readiness of the QR trio, live as it is typed. The Review
Queue refuses to approve a garment without these (the tag +
the phone scan are built from them), so the reason is stated
HERE — where the sample's hangtag is still in hand. */}
<QrReadinessNote gid={g.id} fields={g.fields} />
{/* co-ord toggle — mirrors single upload */}
<div className="flex items-center justify-between py-3 border-t border-line">
<div>
<p className="text-[12.5px] text-ink">Co-ord piece</p>
<p className="font-plex text-[10px] text-muted">Part of a matching set</p>
</div>
<button
type="button"
aria-label={`Toggle co-ord for group ${g.id}`}
onClick={() => patchGroup(g.id, (gg) => ({ ...gg, fields: { ...gg.fields, isCoord: !gg.fields.isCoord } }))}
data-testid={`bk-coord-${g.id}`}
className={'w-11 h-6 rounded-full relative transition-colors ' + (g.fields.isCoord ? 'bg-ink' : 'bg-line')}
>
<div className={'absolute top-1 w-4 h-4 rounded-full bg-ivory transition-all ' + (g.fields.isCoord ? 'left-6' : 'left-1')} />
</button>
</div>
{/* collapsible AI tags (bulk preview returns roles only → start blank) */}
<div className="border border-line">
<button
type="button"
data-testid={`bk-tags-toggle-${g.id}`}
onClick={() => setOpenTags((p) => { const n = new Set(p); if (n.has(g.id)) n.delete(g.id); else n.add(g.id); return n; })}
className="w-full flex items-center justify-between px-3.5 py-2.5 text-left"
>
<span className="font-plex text-[9px] uppercase tracking-[0.16em] text-muted">AI tags{filledTags ? ` · ${filledTags} filled` : ' · optional'}{hiddenTags > 0 ? ` · ${hiddenTags} correctable in review` : ''}</span>
<span className="text-[10px] text-muted">{tagsOpen ? '▴' : '▾'}</span>
</button>
{tagsOpen && (
<div className="px-3.5 pb-3.5 grid grid-cols-1 sm:grid-cols-2 gap-3 border-t border-line pt-3">
{TAG_FIELDS.map((f) => (
<div key={f.key}>
<label className={labelCls}>{f.label}</label>
<input
className={inputCls}
value={g.fields.tags[f.key] ?? ''}
onChange={(e) => toggleTag(g.id, f.key, e.target.value)}
placeholder="—"
data-testid={`bk-tag-${g.id}-${f.key}`}
/>
</div>
))}
{/* Colour: structured palette (name + hex + role) — the
hexes are preserved on save instead of being
flattened to readable text (#651/#652). */}
<div className="sm:col-span-2">
<label className={labelCls}>Colour</label>
<ColourPaletteInput
value={g.fields.palette}
onChange={(next) => setGroupPalette(g.id, next)}
/>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Unassigned tray */}
{unassigned.length > 0 && (
<div data-testid="unassigned-tray" className="border border-dashed border-[#9a7322]/50 bg-[#f7f1e2] rounded shadow-card p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div>
<p className="font-display italic text-[15px] text-ink">Unassigned</p>
<p className="font-plex text-[9px] text-muted mt-0.5">{unassigned.length} shot{unassigned.length !== 1 ? 's' : ''} not in a garment — group them into a new one.</p>
</div>
<button type="button" data-testid="group-unassigned" onClick={createGroupFromUnassigned} disabled={selUnassigned.size === 0} className={btnGhost + ' disabled:opacity-30 disabled:cursor-not-allowed'}>
Group selected ({selUnassigned.size}) → new garment
</button>
</div>
<div className="flex flex-wrap gap-2">
{unassigned.map((idx) => {
const meta = shotMeta[idx];
return (
<div key={idx} className="w-24 flex flex-col gap-1.5 border border-[#9a7322]/30 bg-paper p-2">
{urls[idx] ? <img src={urls[idx]} alt={`Unassigned ${idx + 1}`} className="w-full h-[60px] object-cover border border-line" /> : <div className="w-full h-[60px] border border-line bg-ivory" />}
<label className="flex items-center gap-1.5 cursor-pointer text-[10px] text-ink">
<input type="checkbox" data-testid={`select-unassigned-${idx}`} checked={selUnassigned.has(idx)} onChange={() => setSelUnassigned((p) => { const n = new Set(p); if (n.has(idx)) n.delete(idx); else n.add(idx); return n; })} className="accent-ink" />
Shot {idx + 1}
</label>
<div data-testid={`role-unassigned-${idx}`}>
<Dropdown
value={meta?.role ?? 'unknown'}
onChange={(v) => setShotRole(idx, v)}
options={SHOT_ROLE_OPTIONS}
buttonClassName="h-7 text-[10px]"
/>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Save bar */}
<div className="sticky bottom-0 z-30 border border-line bg-ivory rounded shadow-card-lg p-4 flex items-center justify-between gap-3">
<div>
<p className="text-[13px] text-ink font-medium">{groupedCount} garment{groupedCount !== 1 ? 's' : ''} ready</p>
<p className="font-plex text-[10px] text-muted mt-0.5">Each gets an auto style number · category required</p>
</div>
<div className="flex flex-wrap items-center gap-3 justify-end">
<button
type="button"
data-testid="rapid-send-background"
onClick={() => void handleSendToBackground()}
disabled={!canSave}
className={btnGhost + ' disabled:opacity-30 disabled:cursor-not-allowed'}
>
Send to background
</button>
<button type="button" data-testid="bk-save" onClick={() => void handleSave()} disabled={!canSave} className={btnPrimary + ' disabled:opacity-30'}>
{busy === 'saving' ? 'Saving garments…' : busy === 'grouping' ? 'Working…' : `Save ${groupedCount} garment${groupedCount !== 1 ? 's' : ''}`}
</button>
</div>
</div>
{busy === 'saving' && (
<div className="p-4 border border-line bg-ivory flex items-center gap-3 rounded shadow-card">
<UploadLottie size={44} className="shrink-0" />
<p className="text-[12.5px] font-medium text-ink">Saving {groupedCount} garment{groupedCount !== 1 ? 's' : ''}…</p>
</div>
)}
{/* Re-run button — backend clusters once, so manual edits only */}
<button type="button" onClick={clearBatch} className="self-start text-[12px] text-muted hover:text-ink transition-colors">Start a new batch</button>
</div>
)}
{/* Issue #530: image-duplicate override dialog — app-native ConfirmDialog
(never window.confirm). Confirming retries the save with force=true so
the user can knowingly save a re-used photo under a new style. */}
<ConfirmDialog
open={dupConflict !== null}
danger
title="Duplicate photo detected"
message={dupConflict?.message ?? ''}
confirmLabel="This is a different garment — save anyway"
onCancel={() => setDupConflict(null)}
onConfirm={() => { const c = dupConflict; setDupConflict(null); if (c) void handleSave(true); }}
>
{dupConflict && (
<p className="font-plex text-[10px] uppercase tracking-[0.14em] text-muted mt-2.5">
Matches {dupConflict.styleNumber} · pHash Δ {dupConflict.distance}
{dupConflict.groupId !== null ? ` · Garment ${dupConflict.groupId + 1}` : ''}
</p>
)}
</ConfirmDialog>
{sheetJob && <CaptureJobSheet rec={sheetJob} onClose={() => setSheetJob(null)} />}
{/* #688: QR printer — a SIBLING of the other modal roots (never nested
inside another modal/backdrop: an inner backdrop's onClick bubbles and
closes both). QrPrinterModal renders its own inline error when the
QR-mandatory fields are missing, so no extra handling here. */}
{qrGarment && (
<QrPrinterModal open garmentId={qrGarment} onClose={() => setQrGarment(null)} />
)}
</main>
</div>
);
}