EditArchiveModal.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. import { useState, useEffect, useMemo, useRef } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { useAuth } from '../contexts/AuthContext';
  7. import type { Archive } from '../api/client';
  8. import { Button } from './Button';
  9. import { PrintLogTable } from './PrintLogTable';
  10. import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
  11. import { assignableProjects } from '../utils/projectTree';
  12. import { verdictSourceKey } from '../utils/verdictSource';
  13. import { NumberInput } from './NumberInput';
  14. // Keys for failure reasons - translated at render time.
  15. // Exported so the Print Log per-row classification editor (#1687 part 4)
  16. // can share the same vocabulary as the Archive Edit modal — the backend
  17. // PATCH /print-log/{id} validator gates writes against this exact list.
  18. export const FAILURE_REASON_KEYS = [
  19. 'adhesionFailure',
  20. 'spaghettiDetached',
  21. 'layerShift',
  22. 'cloggedNozzle',
  23. 'filamentRunout',
  24. 'warping',
  25. 'stringing',
  26. 'underExtrusion',
  27. 'powerFailure',
  28. 'userCancelled',
  29. 'noStatusUpdate',
  30. 'killSwitch',
  31. 'other',
  32. ] as const;
  33. // The finished outcomes an archive can be given by hand (the API refuses any
  34. // other). Translated at render time.
  35. const ARCHIVE_STATUS_KEYS: readonly string[] = ['completed', 'failed', 'aborted', 'cancelled'];
  36. // Mirrors the API's own bound on filament_used_grams. Clamped here as well so
  37. // the field cannot produce a request the backend would reject with a 422 —
  38. // this modal has no error surface, so a refused save looks like nothing
  39. // happened at all (#1820).
  40. const MAX_FILAMENT_GRAMS = 100000;
  41. interface EditArchiveModalProps {
  42. archive: Archive;
  43. onClose: () => void;
  44. existingTags?: string[];
  45. }
  46. export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
  47. const { t } = useTranslation();
  48. // Close on Escape key
  49. useEffect(() => {
  50. const handleKeyDown = (e: KeyboardEvent) => {
  51. if (e.key === 'Escape') onClose();
  52. };
  53. window.addEventListener('keydown', handleKeyDown);
  54. return () => window.removeEventListener('keydown', handleKeyDown);
  55. }, [onClose]);
  56. const queryClient = useQueryClient();
  57. const [printName, setPrintName] = useState(archive.print_name || '');
  58. const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
  59. const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
  60. const [notes, setNotes] = useState(archive.notes || '');
  61. const [tags, setTags] = useState(archive.tags || '');
  62. // Failure reason is stored as a camelCase key (`filamentRunout`). Three older
  63. // writers stored other spellings -- English display labels from the backend,
  64. // this modal's own translated labels, and two prose sentences from the stale
  65. // archive paths -- and a startup migration folds all of them onto keys
  66. // (issue #2974). This reverse lookup is the belt to that migration's braces,
  67. // for a frontend running against a backend that has not restarted yet.
  68. //
  69. // A value it cannot resolve is kept rather than dropped. It used to fall back
  70. // to '', which did not merely look wrong: the empty selection was then saved
  71. // over the stored text, so opening the editor on an archive whose reason was
  72. // free text and pressing Save silently destroyed the classification. Anything
  73. // unrecognised now shows up as its own option (see `unmappedReason` below).
  74. const [failureReason, setFailureReason] = useState(() => {
  75. const raw = archive.failure_reason || '';
  76. if (!raw) return '';
  77. if ((FAILURE_REASON_KEYS as readonly string[]).includes(raw)) return raw;
  78. const match = FAILURE_REASON_KEYS.find(
  79. (k) => t(`editArchive.failureReasons.${k}`) === raw,
  80. );
  81. return match || raw;
  82. });
  83. // The stored value when it is not part of the vocabulary, so the dropdown can
  84. // offer it verbatim instead of appearing empty over a reason that exists.
  85. const unmappedReason =
  86. failureReason && !(FAILURE_REASON_KEYS as readonly string[]).includes(failureReason)
  87. ? failureReason
  88. : null;
  89. const [status, setStatus] = useState(archive.status);
  90. // Post-print quality verdict (#1898): '' = none, 'good' / 'reject'
  91. const [userVerdict, setUserVerdict] = useState<string>(archive.user_verdict ?? '');
  92. const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
  93. const [quantity, setQuantity] = useState(archive.quantity ?? 1);
  94. // Kept as a string so the field can be genuinely empty: a print archived
  95. // without its 3MF has no figure at all, and "" has to stay distinguishable
  96. // from 0 both on the way in and on the way out (#1820).
  97. const [filamentGrams, setFilamentGrams] = useState(
  98. archive.filament_used_grams != null ? String(archive.filament_used_grams) : ''
  99. );
  100. const { hasPermission } = useAuth();
  101. // Later prints of the archive are charged from its weight: without the right
  102. // to edit every archive, it can only be filled in for an archive without its
  103. // 3MF, which has none and cannot be printed again (the API refuses the rest).
  104. const filamentGramsLocked =
  105. !hasPermission('archives:update_all') && (archive.filament_used_grams != null || !!archive.file_path);
  106. const [photos, setPhotos] = useState<string[]>(archive.photos || []);
  107. const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
  108. const [uploadingPhoto, setUploadingPhoto] = useState(false);
  109. const [showTagSuggestions, setShowTagSuggestions] = useState(false);
  110. const tagInputRef = useRef<HTMLInputElement>(null);
  111. const photoInputRef = useRef<HTMLInputElement>(null);
  112. const blurTimeoutRef = useRef<number | null>(null);
  113. const { data: printers } = useQuery({
  114. queryKey: ['printers'],
  115. queryFn: api.getPrinters,
  116. });
  117. const { data: projects } = useQuery({
  118. queryKey: ['projects'],
  119. queryFn: () => api.getProjects(),
  120. select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
  121. });
  122. // Archived projects drop off the list, except the one this archive is
  123. // already filed under. That one has to stay: a select holding a value with
  124. // no matching option resets to the first one, so the field would read
  125. // "No project" for an archive that is in one (#2888).
  126. const projectOptions = useMemo(
  127. () => assignableProjects(projects ?? [], archive.project_id),
  128. [projects, archive.project_id],
  129. );
  130. // Fetch all tags using the dedicated API
  131. const { data: tagsData } = useQuery({
  132. queryKey: ['tags'],
  133. queryFn: api.getTags,
  134. enabled: existingTags.length === 0,
  135. });
  136. // Use existing tags prop if provided, otherwise use fetched tags
  137. const allTags = existingTags.length > 0
  138. ? existingTags
  139. : (tagsData?.map(t => t.name) || []);
  140. // Get current tags as array
  141. const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
  142. // Get the text being typed after the last comma (for autocomplete filtering)
  143. const currentInput = tags.includes(',')
  144. ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
  145. : tags.trim().toLowerCase();
  146. // Filter suggestions: not already added AND matches current input (if any)
  147. const tagSuggestions = allTags.filter(t =>
  148. !currentTags.includes(t) &&
  149. (currentInput === '' || t.toLowerCase().includes(currentInput))
  150. );
  151. // Add a tag (replaces any partial input with the selected tag)
  152. const addTag = (tag: string) => {
  153. // If there's partial input being typed, replace it with the selected tag
  154. // Otherwise, just append the tag
  155. let baseTags: string[];
  156. if (currentInput && !allTags.includes(currentInput)) {
  157. // User is typing a partial tag - replace it with the selected one
  158. baseTags = tags.includes(',')
  159. ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
  160. : [];
  161. } else {
  162. // No partial input or input is already a complete tag - append
  163. baseTags = currentTags;
  164. }
  165. if (!baseTags.includes(tag)) {
  166. const newTags = [...baseTags, tag].join(', ');
  167. setTags(newTags);
  168. }
  169. // Clear any pending blur timeout to prevent hiding suggestions
  170. if (blurTimeoutRef.current !== null) {
  171. clearTimeout(blurTimeoutRef.current);
  172. }
  173. tagInputRef.current?.focus();
  174. };
  175. // Remove a tag
  176. const removeTag = (tagToRemove: string) => {
  177. const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
  178. setTags(newTags);
  179. };
  180. const updateMutation = useMutation({
  181. mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
  182. api.updateArchive(archive.id, data),
  183. onSuccess: () => {
  184. // This form can change the archive's project, so the project detail
  185. // views need refreshing too — not just the overview cards (#2731).
  186. invalidateArchiveAndProjectViews(queryClient);
  187. // Some of what this form writes is mirrored onto the archive's most
  188. // recent run — status and failure reason since #1444, filament grams
  189. // since #1820 — and the Print Log this modal renders at its top reads
  190. // the runs through their own query. Without this it serves the cached
  191. // pre-edit row, so the correction looks like it did not take.
  192. queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
  193. onClose();
  194. },
  195. });
  196. const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  197. const file = e.target.files?.[0];
  198. if (!file) return;
  199. setUploadingPhoto(true);
  200. try {
  201. const result = await api.uploadArchivePhoto(archive.id, file);
  202. setPhotos(result.photos);
  203. queryClient.invalidateQueries({ queryKey: ['archives'] });
  204. } catch (error) {
  205. console.error('Failed to upload photo:', error);
  206. } finally {
  207. setUploadingPhoto(false);
  208. if (photoInputRef.current) {
  209. photoInputRef.current.value = '';
  210. }
  211. }
  212. };
  213. const handlePhotoDelete = async (filename: string) => {
  214. try {
  215. const result = await api.deleteArchivePhoto(archive.id, filename);
  216. setPhotos(result.photos || []);
  217. queryClient.invalidateQueries({ queryKey: ['archives'] });
  218. } catch (error) {
  219. console.error('Failed to delete photo:', error);
  220. }
  221. };
  222. const handleSubmit = (e: React.FormEvent) => {
  223. e.preventDefault();
  224. // Build update data
  225. const updateData: Parameters<typeof api.updateArchive>[1] = {
  226. print_name: printName || undefined,
  227. printer_id: printerId,
  228. project_id: projectId,
  229. notes: notes || undefined,
  230. tags: tags || undefined,
  231. quantity: quantity,
  232. external_url: externalUrl || null,
  233. };
  234. // Only include status if changed
  235. if (status !== archive.status) {
  236. updateData.status = status;
  237. }
  238. // Verdict only when touched, same reasoning as status; '' clears it.
  239. if (userVerdict !== (archive.user_verdict ?? '')) {
  240. updateData.user_verdict = userVerdict === '' ? null : (userVerdict as 'good' | 'reject');
  241. updateData.user_verdict_source = 'dialog';
  242. }
  243. // Sent only when the user actually touched it, so an ordinary save of an
  244. // archive that has its 3MF cannot overwrite the sliced figure with a
  245. // rounded one from the input.
  246. const trimmedGrams = filamentGrams.trim();
  247. const typedGrams = trimmedGrams === '' ? null : Number(trimmedGrams);
  248. // An empty field means "no figure" and clears the stored one; a field that
  249. // holds something unparseable (a lone decimal point, mid-typing) means the
  250. // user is not finished, and must not read as a clear. Clamped here as well
  251. // as on blur because Enter submits without the field losing focus.
  252. const gramsUnparseable = typedGrams !== null && !Number.isFinite(typedGrams);
  253. const parsedGrams = typedGrams === null ? null : Math.min(Math.max(typedGrams, 0), MAX_FILAMENT_GRAMS);
  254. const originalGrams = archive.filament_used_grams ?? null;
  255. if (!gramsUnparseable && parsedGrams !== originalGrams) {
  256. updateData.filament_used_grams = parsedGrams;
  257. }
  258. // Handle failure_reason based on status. A completed-but-rejected print
  259. // (#1898) uses the same field as its reject reason.
  260. if (status === 'failed' || status === 'aborted') {
  261. updateData.failure_reason = failureReason || undefined;
  262. } else if (status === 'completed' && userVerdict === 'reject') {
  263. updateData.failure_reason = failureReason || undefined;
  264. } else if (status === 'completed' && archive.failure_reason) {
  265. // A completed print without a reject carries no reason. A cancelled one
  266. // keeps its own: the machine wrote it ("User cancelled", "No status
  267. // update received") and the dialog has no field to put it back.
  268. updateData.failure_reason = null;
  269. }
  270. updateMutation.mutate(updateData);
  271. };
  272. return (
  273. <div
  274. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  275. onClick={onClose}
  276. >
  277. <div
  278. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
  279. onClick={(e) => e.stopPropagation()}
  280. >
  281. {/* Header */}
  282. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  283. <h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
  284. <button
  285. onClick={onClose}
  286. className="text-bambu-gray hover:text-white transition-colors"
  287. >
  288. <X className="w-5 h-5" />
  289. </button>
  290. </div>
  291. {/* Form */}
  292. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
  293. {/* Print Log — per-run history pulled from PrintLogEntry (#1378). Shown
  294. first so users can see which runs contributed to the aggregate stats. */}
  295. <div>
  296. <label className="block text-sm text-bambu-gray mb-1">{t('archives.runLog.title')}</label>
  297. <PrintLogTable archiveId={archive.id} />
  298. </div>
  299. {/* Print Name */}
  300. <div>
  301. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
  302. <input
  303. type="text"
  304. value={printName}
  305. onChange={(e) => setPrintName(e.target.value)}
  306. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  307. placeholder={t('editArchive.namePlaceholder')}
  308. />
  309. </div>
  310. {/* Printer */}
  311. <div>
  312. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
  313. <select
  314. value={printerId ?? ''}
  315. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  316. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  317. >
  318. <option value="">{t('editArchive.noPrinter')}</option>
  319. {printers?.map((p) => (
  320. <option key={p.id} value={p.id}>
  321. {p.name}
  322. </option>
  323. ))}
  324. </select>
  325. </div>
  326. {/* Project */}
  327. <div>
  328. <label className="block text-sm text-bambu-gray mb-1">
  329. <FolderKanban className="w-4 h-4 inline mr-1" />
  330. {t('editArchive.project')}
  331. </label>
  332. <select
  333. value={projectId ?? ''}
  334. onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
  335. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  336. >
  337. <option value="">{t('editArchive.noProject')}</option>
  338. {projectOptions.map((p) => (
  339. <option key={p.id} value={p.id}>
  340. {p.name}
  341. </option>
  342. ))}
  343. </select>
  344. </div>
  345. {/* Quantity - number of items printed */}
  346. <div>
  347. <label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-items-printed">
  348. <Hash className="w-4 h-4 inline mr-1" />
  349. {t('editArchive.itemsPrinted')}
  350. </label>
  351. <NumberInput
  352. id="archive-items-printed"
  353. min={0}
  354. value={quantity}
  355. // 0 is a real answer, not an empty field: a plate that jammed and
  356. // came off ruined produced nothing, and the project's completed
  357. // count has to be able to say so (#3051).
  358. onChange={setQuantity}
  359. fallback={0}
  360. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  361. placeholder="1"
  362. />
  363. <p className="text-xs text-bambu-gray mt-1">
  364. {t('editArchive.itemsPrintedHelp')}
  365. </p>
  366. </div>
  367. {/* Filament used - the only way to supply a figure for a print that
  368. archived without its 3MF, which has nothing to read it from (#1820). */}
  369. <div>
  370. <label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-filament-grams">
  371. <Weight className="w-4 h-4 inline mr-1" />
  372. {t('editArchive.filamentUsed')}
  373. </label>
  374. <input
  375. id="archive-filament-grams"
  376. type="text"
  377. inputMode="decimal"
  378. value={filamentGrams}
  379. // Text rather than number, and filtered on the way in. A number
  380. // input reports an empty string for anything the browser judges
  381. // malformed — including a decimal comma in a locale it doesn't
  382. // expect — which would read here as "the user cleared it" and
  383. // wipe a good figure. Filtering keeps what is displayed and what
  384. // would be sent the same thing, and keeps the value inside the
  385. // range the API accepts: this modal shows nothing at all when a
  386. // save is refused, so it must not be able to send a refusable one.
  387. onChange={(e) => {
  388. const next = e.target.value.replace(',', '.');
  389. if (next === '' || /^\d*\.?\d*$/.test(next)) setFilamentGrams(next);
  390. }}
  391. onBlur={() => setFilamentGrams((current) => {
  392. const parsed = Number(current);
  393. if (current === '' || !Number.isFinite(parsed)) return '';
  394. return String(Math.min(parsed, MAX_FILAMENT_GRAMS));
  395. })}
  396. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
  397. placeholder={t('editArchive.filamentUsedPlaceholder')}
  398. disabled={filamentGramsLocked}
  399. />
  400. <p className="text-xs text-bambu-gray mt-1">
  401. {filamentGramsLocked ? t('editArchive.filamentUsedLocked') : t('editArchive.filamentUsedHelp')}
  402. </p>
  403. </div>
  404. {/* Notes */}
  405. <div>
  406. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
  407. <textarea
  408. value={notes}
  409. onChange={(e) => setNotes(e.target.value)}
  410. rows={3}
  411. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none"
  412. placeholder={t('editArchive.notesPlaceholder')}
  413. />
  414. </div>
  415. {/* External Link */}
  416. <div>
  417. <label className="block text-sm text-bambu-gray mb-1">
  418. <Link className="w-4 h-4 inline mr-1" />
  419. {t('editArchive.externalLink')}
  420. </label>
  421. <input
  422. type="url"
  423. value={externalUrl}
  424. onChange={(e) => setExternalUrl(e.target.value)}
  425. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  426. placeholder="https://printables.com/model/..."
  427. />
  428. <p className="text-xs text-bambu-gray mt-1">
  429. {t('editArchive.externalLinkHelp')}
  430. </p>
  431. </div>
  432. {/* Tags */}
  433. <div>
  434. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
  435. {/* Current tags as chips */}
  436. {currentTags.length > 0 && (
  437. <div className="flex flex-wrap gap-1.5 mb-2">
  438. {currentTags.map((tag) => (
  439. <span
  440. key={tag}
  441. className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
  442. >
  443. <Tag className="w-3 h-3" />
  444. {tag}
  445. <button
  446. type="button"
  447. onClick={() => removeTag(tag)}
  448. className="ml-0.5 text-bambu-gray hover:text-white"
  449. >
  450. <X className="w-3 h-3" />
  451. </button>
  452. </span>
  453. ))}
  454. </div>
  455. )}
  456. {/* Tag input with suggestions */}
  457. <div className="relative">
  458. <input
  459. ref={tagInputRef}
  460. type="text"
  461. value={tags}
  462. onChange={(e) => setTags(e.target.value)}
  463. onFocus={() => {
  464. if (blurTimeoutRef.current !== null) {
  465. clearTimeout(blurTimeoutRef.current);
  466. }
  467. setShowTagSuggestions(true);
  468. }}
  469. onBlur={() => {
  470. blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
  471. }}
  472. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  473. placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
  474. />
  475. {/* Suggestions dropdown */}
  476. {showTagSuggestions && tagSuggestions.length > 0 && (
  477. <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
  478. <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
  479. {currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
  480. </div>
  481. <div className="p-2 flex flex-wrap gap-1.5">
  482. {tagSuggestions.map((tag) => (
  483. <button
  484. key={tag}
  485. type="button"
  486. onClick={() => addTag(tag)}
  487. className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
  488. >
  489. {tag}
  490. </button>
  491. ))}
  492. </div>
  493. </div>
  494. )}
  495. </div>
  496. </div>
  497. {/* Status */}
  498. <div>
  499. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
  500. <select
  501. value={status}
  502. onChange={(e) => {
  503. setStatus(e.target.value);
  504. // Clear failure reason when changing to completed
  505. if (e.target.value === 'completed') {
  506. setFailureReason('');
  507. }
  508. }}
  509. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  510. >
  511. {/* Any other status (still printing, never printed) is shown as it
  512. is, and is only sent if the user picks an outcome. */}
  513. {archive.status && !ARCHIVE_STATUS_KEYS.includes(archive.status) && (
  514. <option value={archive.status} disabled>
  515. {t(`editArchive.statuses.${archive.status}`, { defaultValue: archive.status })}
  516. </option>
  517. )}
  518. {ARCHIVE_STATUS_KEYS.map((statusKey) => (
  519. <option key={statusKey} value={statusKey}>
  520. {t(`editArchive.statuses.${statusKey}`)}
  521. </option>
  522. ))}
  523. </select>
  524. <p className="text-xs text-bambu-gray mt-1">{t('editArchive.statusHelp')}</p>
  525. </div>
  526. {/* Outcome verdict (#1898) - only meaningful on completed prints */}
  527. {status === 'completed' && (
  528. <div>
  529. <label htmlFor="user-verdict-select" className="block text-sm text-bambu-gray mb-1">
  530. {t('editArchive.userVerdict')}
  531. </label>
  532. <select
  533. id="user-verdict-select"
  534. value={userVerdict}
  535. onChange={(e) => setUserVerdict(e.target.value)}
  536. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  537. >
  538. <option value="">{t('editArchive.verdicts.none')}</option>
  539. <option value="good">{t('editArchive.verdicts.good')}</option>
  540. <option value="reject">{t('editArchive.verdicts.reject')}</option>
  541. </select>
  542. {/* How the stored verdict got here (#1898) — hidden once the
  543. select no longer shows that verdict, since the hint would
  544. then describe an answer the user is replacing. */}
  545. {verdictSourceHintKey && userVerdict === (archive.user_verdict ?? '') && (
  546. <p className="text-xs text-bambu-gray/70 mt-1" data-testid="verdict-source-hint">
  547. {t(verdictSourceHintKey)}
  548. </p>
  549. )}
  550. </div>
  551. )}
  552. {/* Failure Reason - shown for failed/aborted prints and for
  553. completed-but-rejected ones (#1898: the reject reason reuses
  554. this field and vocabulary) */}
  555. {(status === 'failed' || status === 'aborted' || (status === 'completed' && userVerdict === 'reject')) && (
  556. <div>
  557. <label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
  558. <select
  559. id="failure-reason-select"
  560. value={failureReason}
  561. onChange={(e) => setFailureReason(e.target.value)}
  562. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  563. >
  564. <option value="">{t('editArchive.selectReason')}</option>
  565. {FAILURE_REASON_KEYS.map((reasonKey) => (
  566. <option key={reasonKey} value={reasonKey}>
  567. {t(`editArchive.failureReasons.${reasonKey}`)}
  568. </option>
  569. ))}
  570. {/* A stored reason outside the vocabulary keeps its own option
  571. so it stays visible and survives a save (issue #2974). */}
  572. {unmappedReason && (
  573. <option value={unmappedReason}>{unmappedReason}</option>
  574. )}
  575. </select>
  576. </div>
  577. )}
  578. {/* Photos */}
  579. <div>
  580. <label className="block text-sm text-bambu-gray mb-1">
  581. <Camera className="w-4 h-4 inline mr-1" />
  582. {t('editArchive.photos')}
  583. </label>
  584. {/* Photo grid */}
  585. <div className="flex flex-wrap gap-2 mb-2">
  586. {photos.map((filename) => (
  587. <div key={filename} className="relative group">
  588. <img
  589. src={api.getArchivePhotoUrl(archive.id, filename)}
  590. alt={t('editArchive.printResult')}
  591. className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
  592. />
  593. <button
  594. type="button"
  595. onClick={() => handlePhotoDelete(filename)}
  596. className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
  597. >
  598. <Trash2 className="w-3 h-3 text-white" />
  599. </button>
  600. </div>
  601. ))}
  602. {/* Upload button */}
  603. <label className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors">
  604. <input
  605. ref={photoInputRef}
  606. type="file"
  607. accept="image/jpeg,image/png,image/webp"
  608. onChange={handlePhotoUpload}
  609. className="hidden"
  610. disabled={uploadingPhoto}
  611. />
  612. {uploadingPhoto ? (
  613. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  614. ) : (
  615. <Plus className="w-6 h-6 text-bambu-gray" />
  616. )}
  617. </label>
  618. </div>
  619. <p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
  620. </div>
  621. {/* Actions */}
  622. <div className="flex gap-3 pt-2">
  623. <Button
  624. type="button"
  625. variant="secondary"
  626. onClick={onClose}
  627. className="flex-1"
  628. >
  629. {t('common.cancel')}
  630. </Button>
  631. <Button
  632. type="submit"
  633. disabled={updateMutation.isPending}
  634. className="flex-1"
  635. >
  636. <Save className="w-4 h-4" />
  637. {updateMutation.isPending ? t('common.saving') : t('common.save')}
  638. </Button>
  639. </div>
  640. </form>
  641. </div>
  642. </div>
  643. );
  644. }