// frontend/src/pages/manufacturing/firmware/FlashAssetManager.jsx import { useState, useEffect, useCallback } from 'react' import api from '@/lib/api' import { useAuth } from '@/hooks/useAuth' import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import StatusBadge from '@/components/ui/StatusBadge' import Spinner from '@/components/ui/Spinner' import Icon from '@/components/ui/Icon' import Modal from '@/components/ui/Modal' import FormField from '@/components/ui/FormField' import ConfirmDialog from '@/components/ui/ConfirmDialog' import UploadAssetModal from '@/modals/engineering/manufacturing/UploadAssetModal' import { fmtDateTimeMedium } from '@/lib/formatters' // ─── Constants ──────────────────────────────────────────────────────────────── const KNOWN_BOARD_TYPES = [ { value: 'vesper', label: 'Vesper', family: 'vesper' }, { value: 'vesper_plus', label: 'Vesper+', family: 'vesper' }, { value: 'vesper_pro', label: 'Vesper Pro', family: 'vesper' }, { value: 'agnus', label: 'Agnus', family: 'agnus' }, { value: 'agnus_mini', label: 'Agnus Mini', family: 'agnus' }, { value: 'chronos', label: 'Chronos', family: 'chronos' }, { value: 'chronos_pro', label: 'Chronos Pro', family: 'chronos' }, ] const FAMILY_ACCENT = { vesper: 'var(--color-info)', agnus: 'var(--color-warning)', chronos: 'var(--color-danger)', bespoke: 'var(--color-primary)', } const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label])) // ─── Helpers ────────────────────────────────────────────────────────────────── function formatBytes(bytes) { if (!bytes) return '—' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(2)} MB` } function formatDate(iso) { return fmtDateTimeMedium(iso) } function boardLabel(hwType, isBespoke) { if (isBespoke) return hwType return BOARD_LABELS[hwType] || hwType } function familyOf(hwType, isBespoke) { if (isBespoke) return 'bespoke' return KNOWN_BOARD_TYPES.find((b) => b.value === hwType)?.family ?? 'vesper' } // ─── Note Modal ─────────────────────────────────────────────────────────────── function NoteModal({ open, hwType, currentNote, onClose, onSaved }) { const [note, setNote] = useState(currentNote || '') const [saving, setSaving] = useState(false) const [error, setError] = useState('') const handleSave = async () => { setSaving(true); setError('') try { await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note }) onSaved(note) onClose() } catch (err) { setError(err.message || 'Failed to save note') } finally { setSaving(false) } } return ( } >

Board: {hwType} {' '}— stored alongside binaries as{' '} note.txt

{error && (
{error}
)} setNote(e.target.value)} placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234…" hint="Leave blank to clear the note." />
) } // ─── Asset Pill ─────────────────────────────────────────────────────────────── function AssetPill({ label, info, canDelete, onUpload, onDelete }) { const exists = info?.exists return (

{label}

{exists ? (

{formatBytes(info.size_bytes)}{info.uploaded_at ? ` · ${formatDate(info.uploaded_at)}` : ''}

) : (

Not uploaded

)}
{exists && canDelete && ( )}
) } // ─── Board Card ─────────────────────────────────────────────────────────────── function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onDeleteNote }) { const { hw_type, bootloader, partitions, note, is_bespoke } = entry const family = familyOf(hw_type, is_bespoke) const accent = FAMILY_ACCENT[family] || 'var(--color-primary)' const label = boardLabel(hw_type, is_bespoke) const hasBootloader = bootloader?.exists const hasPartitions = partitions?.exists const hasAll = hasBootloader && hasPartitions const hasNone = !hasBootloader && !hasPartitions const readyVariant = hasAll ? 'success' : hasNone ? 'danger' : 'warning' const readyLabel = hasAll ? 'Ready' : hasNone ? 'Missing' : 'Partial' const [noteHovered, setNoteHovered] = useState(false) return (
{/* Header */}
{label} {is_bespoke && bespoke} {hw_type}
{readyLabel} {canEdit && ( )}
{/* Asset pills */}
onUpload(hw_type, 'bootloader.bin')} onDelete={() => onDelete(hw_type, 'bootloader.bin')} /> onUpload(hw_type, 'partitions.bin')} onDelete={() => onDelete(hw_type, 'partitions.bin')} />
{/* Note — shown below the files, hover-X to delete */} {note && (
setNoteHovered(true)} onMouseLeave={() => setNoteHovered(false)} style={{ padding: 'var(--space-2) var(--space-4)', backgroundColor: 'var(--color-bg-elevated)', borderTop: '1px solid var(--color-border)', display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)', }} >

{note}

{canEdit && noteHovered && ( )}
)}
) } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function FlashAssetManager({ embedded = false }) { const { hasPermission } = useAuth() const canDelete = hasPermission('manufacturing', 'delete') const canEdit = hasPermission('manufacturing', 'edit') const [assets, setAssets] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [filter, setFilter] = useState('all') const [uploadTarget, setUploadTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [noteTarget, setNoteTarget] = useState(null) const [noteDelTarget, setNoteDelTarget] = useState(null) const [showConfirmDel, setShowConfirmDel] = useState(false) const fetchAssets = useCallback(async () => { setLoading(true); setError('') try { const data = await api.get('/manufacturing/flash-assets') setAssets(data.assets || []) } catch (err) { setError(err.message || 'Failed to load flash assets') } finally { setLoading(false) } }, []) useEffect(() => { fetchAssets() }, [fetchAssets]) const handleDelete = async () => { if (!deleteTarget) return try { const token = localStorage.getItem('access_token') const res = await fetch(`/api/manufacturing/flash-assets/${deleteTarget.hwType}/${deleteTarget.assetName}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Delete failed') setShowConfirmDel(false); setDeleteTarget(null) fetchAssets() } catch (err) { setError(err.message) } } const handleNoteUpdate = (hwType, newNote) => { setAssets((prev) => prev.map((a) => a.hw_type === hwType ? { ...a, note: newNote } : a)) } const handleDeleteNote = async (entry) => { try { await api.put(`/manufacturing/flash-assets/${entry.hw_type}/note`, { note: '' }) handleNoteUpdate(entry.hw_type, '') } catch (err) { setError(err.message || 'Failed to delete note') } finally { setNoteDelTarget(null) } } const filteredAssets = assets.filter((e) => { const hasAll = e.bootloader?.exists && e.partitions?.exists const hasNone = !e.bootloader?.exists && !e.partitions?.exists if (filter === 'ready') return hasAll if (filter === 'missing') return hasNone if (filter === 'partial') return !hasAll && !hasNone return true }) const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length const partialCount = assets.length - readyCount - missingCount const FILTER_OPTIONS = [ { value: 'all', label: `All (${assets.length})` }, { value: 'ready', label: `Ready (${readyCount})` }, { value: 'partial', label: `Partial (${partialCount})` }, { value: 'missing', label: `Missing (${missingCount})` }, ] const content = (
{[ { label: 'Total', value: assets.length, variant: 'neutral' }, { label: 'Ready', value: readyCount, variant: 'success' }, { label: 'Partial', value: partialCount, variant: 'warning' }, { label: 'Missing', value: missingCount, variant: 'danger' }, ].map(({ label, value, variant }) => (
{label} {value}
))}
{FILTER_OPTIONS.map(({ value, label }) => ( ))}
{error && (
{error}
)} {loading ? (
) : filteredAssets.length === 0 ? (

No assets match the current filter.

) : (
{filteredAssets.map((entry) => ( setUploadTarget({ hwType, assetName })} onDelete={(hwType, assetName) => { setDeleteTarget({ hwType, assetName }); setShowConfirmDel(true) }} onNote={(e) => setNoteTarget(e)} onDeleteNote={(e) => setNoteDelTarget(e)} /> ))}
)} setUploadTarget(null)} onSaved={() => { setUploadTarget(null); fetchAssets() }} /> setNoteTarget(null)} onSaved={(newNote) => { if (noteTarget) handleNoteUpdate(noteTarget.hw_type, newNote); setNoteTarget(null) }} /> handleDeleteNote(noteDelTarget)} onCancel={() => setNoteDelTarget(null)} /> { setShowConfirmDel(false); setDeleteTarget(null) }} />
) if (embedded) return content return (
{content}
) }