import { useState, useEffect, 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 } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; import { PrintLogTable } from './PrintLogTable'; // Keys for failure reasons - translated at render time 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; 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 || ''); const [failureReason, setFailureReason] = useState(archive.failure_reason || ''); const [status, setStatus] = useState(archive.status); const [quantity, setQuantity] = useState(archive.quantity ?? 1); 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)), }); // 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: () => { queryClient.invalidateQueries({ queryKey: ['archives'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); 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; } // 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')}

{/* Notes */}