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).
1925 lines
105 KiB
TypeScript
1925 lines
105 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|