import { useState, useEffect, useMemo, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; import { PrintLogTable } from './PrintLogTable'; import { invalidateArchiveAndProjectViews } from '../utils/projectQueries'; import { assignableProjects } from '../utils/projectTree'; // Keys for failure reasons - translated at render time. // Exported so the Print Log per-row classification editor (#1687 part 4) // can share the same vocabulary as the Archive Edit modal — the backend // PATCH /print-log/{id} validator gates writes against this exact list. export const FAILURE_REASON_KEYS = [ 'adhesionFailure', 'spaghettiDetached', 'layerShift', 'cloggedNozzle', 'filamentRunout', 'warping', 'stringing', 'underExtrusion', 'powerFailure', 'userCancelled', 'other', ] as const; // Keys for archive statuses - translated at render time const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const; // Mirrors the API's own bound on filament_used_grams. Clamped here as well so // the field cannot produce a request the backend would reject with a 422 — // this modal has no error surface, so a refused save looks like nothing // happened at all (#1820). const MAX_FILAMENT_GRAMS = 100000; interface EditArchiveModalProps { archive: Archive; onClose: () => void; existingTags?: string[]; } export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) { const { t } = useTranslation(); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const queryClient = useQueryClient(); const [printName, setPrintName] = useState(archive.print_name || ''); const [printerId, setPrinterId] = useState(archive.printer_id); const [projectId, setProjectId] = useState(archive.project_id ?? null); const [notes, setNotes] = useState(archive.notes || ''); const [tags, setTags] = useState(archive.tags || ''); // Failure reason is stored as a camelCase key (`filamentRunout`), but earlier // versions of this modal saved the translated label as the value. Reverse- // lookup any legacy translated text against the current locale so the // dropdown pre-selects the right option, then any save converts it forward. const [failureReason, setFailureReason] = useState(() => { const raw = archive.failure_reason || ''; if (!raw) return ''; if ((FAILURE_REASON_KEYS as readonly string[]).includes(raw)) return raw; const match = FAILURE_REASON_KEYS.find( (k) => t(`editArchive.failureReasons.${k}`) === raw, ); return match || ''; }); const [status, setStatus] = useState(archive.status); const [quantity, setQuantity] = useState(archive.quantity ?? 1); // Kept as a string so the field can be genuinely empty: a print archived // without its 3MF has no figure at all, and "" has to stay distinguishable // from 0 both on the way in and on the way out (#1820). const [filamentGrams, setFilamentGrams] = useState( archive.filament_used_grams != null ? String(archive.filament_used_grams) : '' ); const [photos, setPhotos] = useState(archive.photos || []); const [externalUrl, setExternalUrl] = useState(archive.external_url || ''); const [uploadingPhoto, setUploadingPhoto] = useState(false); const [showTagSuggestions, setShowTagSuggestions] = useState(false); const tagInputRef = useRef(null); const photoInputRef = useRef(null); const blurTimeoutRef = useRef(null); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)), }); // Archived projects drop off the list, except the one this archive is // already filed under. That one has to stay: a select holding a value with // no matching option resets to the first one, so the field would read // "No project" for an archive that is in one (#2888). const projectOptions = useMemo( () => assignableProjects(projects ?? [], archive.project_id), [projects, archive.project_id], ); // Fetch all tags using the dedicated API const { data: tagsData } = useQuery({ queryKey: ['tags'], queryFn: api.getTags, enabled: existingTags.length === 0, }); // Use existing tags prop if provided, otherwise use fetched tags const allTags = existingTags.length > 0 ? existingTags : (tagsData?.map(t => t.name) || []); // Get current tags as array const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean); // Get the text being typed after the last comma (for autocomplete filtering) const currentInput = tags.includes(',') ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase() : tags.trim().toLowerCase(); // Filter suggestions: not already added AND matches current input (if any) const tagSuggestions = allTags.filter(t => !currentTags.includes(t) && (currentInput === '' || t.toLowerCase().includes(currentInput)) ); // Add a tag (replaces any partial input with the selected tag) const addTag = (tag: string) => { // If there's partial input being typed, replace it with the selected tag // Otherwise, just append the tag let baseTags: string[]; if (currentInput && !allTags.includes(currentInput)) { // User is typing a partial tag - replace it with the selected one baseTags = tags.includes(',') ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean) : []; } else { // No partial input or input is already a complete tag - append baseTags = currentTags; } if (!baseTags.includes(tag)) { const newTags = [...baseTags, tag].join(', '); setTags(newTags); } // Clear any pending blur timeout to prevent hiding suggestions if (blurTimeoutRef.current !== null) { clearTimeout(blurTimeoutRef.current); } tagInputRef.current?.focus(); }; // Remove a tag const removeTag = (tagToRemove: string) => { const newTags = currentTags.filter(t => t !== tagToRemove).join(', '); setTags(newTags); }; const updateMutation = useMutation({ mutationFn: (data: Parameters[1]) => api.updateArchive(archive.id, data), onSuccess: () => { // This form can change the archive's project, so the project detail // views need refreshing too — not just the overview cards (#2731). invalidateArchiveAndProjectViews(queryClient); // Some of what this form writes is mirrored onto the archive's most // recent run — status and failure reason since #1444, filament grams // since #1820 — and the Print Log this modal renders at its top reads // the runs through their own query. Without this it serves the cached // pre-edit row, so the correction looks like it did not take. queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] }); onClose(); }, }); const handlePhotoUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploadingPhoto(true); try { const result = await api.uploadArchivePhoto(archive.id, file); setPhotos(result.photos); queryClient.invalidateQueries({ queryKey: ['archives'] }); } catch (error) { console.error('Failed to upload photo:', error); } finally { setUploadingPhoto(false); if (photoInputRef.current) { photoInputRef.current.value = ''; } } }; const handlePhotoDelete = async (filename: string) => { try { const result = await api.deleteArchivePhoto(archive.id, filename); setPhotos(result.photos || []); queryClient.invalidateQueries({ queryKey: ['archives'] }); } catch (error) { console.error('Failed to delete photo:', error); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Build update data const updateData: Parameters[1] = { print_name: printName || undefined, printer_id: printerId, project_id: projectId, notes: notes || undefined, tags: tags || undefined, quantity: quantity, external_url: externalUrl || null, }; // Only include status if changed if (status !== archive.status) { updateData.status = status; } // Sent only when the user actually touched it, so an ordinary save of an // archive that has its 3MF cannot overwrite the sliced figure with a // rounded one from the input. const trimmedGrams = filamentGrams.trim(); const typedGrams = trimmedGrams === '' ? null : Number(trimmedGrams); // An empty field means "no figure" and clears the stored one; a field that // holds something unparseable (a lone decimal point, mid-typing) means the // user is not finished, and must not read as a clear. Clamped here as well // as on blur because Enter submits without the field losing focus. const gramsUnparseable = typedGrams !== null && !Number.isFinite(typedGrams); const parsedGrams = typedGrams === null ? null : Math.min(Math.max(typedGrams, 0), MAX_FILAMENT_GRAMS); const originalGrams = archive.filament_used_grams ?? null; if (!gramsUnparseable && parsedGrams !== originalGrams) { updateData.filament_used_grams = parsedGrams; } // Handle failure_reason based on status if (status === 'failed' || status === 'aborted') { updateData.failure_reason = failureReason || undefined; } else if (archive.status === 'failed' || archive.status === 'aborted') { // Clear failure_reason when changing from failed/aborted to another status updateData.failure_reason = null; } updateMutation.mutate(updateData); }; return (
e.stopPropagation()} > {/* Header */}

{t('editArchive.title')}

{/* Form */}
{/* Print Log — per-run history pulled from PrintLogEntry (#1378). Shown first so users can see which runs contributed to the aggregate stats. */}
{/* Print Name */}
setPrintName(e.target.value)} 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" placeholder={t('editArchive.namePlaceholder')} />
{/* Printer */}
{/* Project */}
{/* Quantity - number of items printed */}
setQuantity(Math.max(1, parseInt(e.target.value) || 1))} 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" placeholder="1" />

{t('editArchive.itemsPrintedHelp')}

{/* Filament used - the only way to supply a figure for a print that archived without its 3MF, which no rescan can repair (#1820). */}
{ const next = e.target.value.replace(',', '.'); if (next === '' || /^\d*\.?\d*$/.test(next)) setFilamentGrams(next); }} onBlur={() => setFilamentGrams((current) => { const parsed = Number(current); if (current === '' || !Number.isFinite(parsed)) return ''; return String(Math.min(parsed, MAX_FILAMENT_GRAMS)); })} 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" placeholder={t('editArchive.filamentUsedPlaceholder')} />

{t('editArchive.filamentUsedHelp')}

{/* Notes */}