Redesign SpoolBuddy dashboard: inline spool cards, full-screen AMS assign modal, filament ID normalization

- Replace TagDetectedModal with inline SpoolInfoCard/UnknownTagCard
    in dashboard right panel (known spools show assign/sync/close,
    unknown tags show add-to-inventory/link/close)
  - Rewrite AssignToAmsModal as full-screen overlay reusing AmsUnitCard,
    with AMS-HT and external slot support, single assignSpool API call
  - Remove printer selector from assign modal (uses top bar selection)
  - Extract filament_id <-> setting_id conversion to shared utility
    (backend/app/utils/filament_ids.py), used by inventory + cloud routes
  - Normalize slicer_filament in assign_spool to derive proper
    tray_info_idx and setting_id for MQTT (was sending setting_id="")
  - Rename SpoolBuddy top bar status label "Online" -> "Backend"
  - Remove weightStable guard from sync weight button
This commit is contained in:
maziggy
2026-03-01 11:56:52 +01:00
parent 8d9894793a
commit 1cf40a5c35
12 changed files with 794 additions and 712 deletions
@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Check, Loader2 } from 'lucide-react';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { api, type AMSUnit, type AMSTray } from '../../api/client';
import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
function getAmsName(id: number): string {
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -11,282 +11,296 @@ function getAmsName(id: number): string {
return `AMS ${id}`;
}
function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
function trayColorToCSS(color: string | null): string {
if (!color) return '#808080';
return `#${color.slice(0, 6)}`;
}
function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
interface AssignToAmsModalProps {
isOpen: boolean;
onClose: () => void;
spool: MatchedSpool;
spool: InventorySpool;
printerId: number | null;
}
export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
const { t } = useTranslation();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(printerId);
const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
const [showSuccess, setShowSuccess] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setSelectedPrinter(printerId);
setSelectedSlot(null);
setShowSuccess(false);
setStatusMessage(null);
setStatusType(null);
}
}, [isOpen, printerId]);
}, [isOpen]);
// Escape key handler
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
if (isOpen) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleKeyDown]);
// Fetch printers
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
enabled: isOpen,
});
// Fetch printer status
const { data: printerStatus } = useQuery({
queryKey: ['printerStatus', selectedPrinter],
queryFn: () => api.getPrinterStatus(selectedPrinter!),
enabled: isOpen && selectedPrinter !== null,
const { data: status } = useQuery<PrinterStatus>({
queryKey: ['printerStatus', printerId],
queryFn: () => api.getPrinterStatus(printerId!),
enabled: isOpen && printerId !== null,
refetchInterval: 5000,
});
// Assignment mutation
const assignMutation = useMutation({
mutationFn: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
api.assignSpool(data),
const { data: printer } = useQuery({
queryKey: ['printer', printerId],
queryFn: () => api.getPrinter(printerId!),
enabled: isOpen && printerId !== null,
});
const isConnected = status?.connected ?? false;
const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
const cachedAmsExtruderMap = useRef<Record<string, number>>({});
useEffect(() => {
if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
cachedAmsExtruderMap.current = status.ams_extruder_map;
}
}, [status?.ams_extruder_map]);
const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
? status.ams_extruder_map
: cachedAmsExtruderMap.current;
const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
if (!isDualNozzle) return null;
const mappedExtruderId = amsExtruderMap[String(amsId)];
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
return extruderId === 1 ? 'L' : 'R';
}, [isDualNozzle, amsExtruderMap]);
// Assign spool to AMS slot — single API call, backend handles both
// DB record AND MQTT auto-configuration (same as SpoolStation).
const configureMutation = useMutation({
mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
if (!printerId) throw new Error('No printer selected');
await api.assignSpool({
spool_id: spool.id,
printer_id: printerId,
ams_id: amsId,
tray_id: trayId,
});
},
onSuccess: () => {
setShowSuccess(true);
setTimeout(() => {
onClose();
}, 1500);
setStatusType('success');
setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
setTimeout(() => onClose(), 1500);
},
onError: (err) => {
setStatusType('error');
setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
},
});
const isWaiting = configureMutation.isPending;
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
if (isWaiting) return;
setStatusType('info');
setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
configureMutation.mutate({ amsId, trayId });
}, [isWaiting, configureMutation, t]);
// Build single-slot items (HT + External)
const singleSlots = useMemo(() => {
const items: {
key: string; label: string; amsId: number; trayId: number;
tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
}[] = [];
for (const unit of htAms) {
const tray = unit.tray?.[0] || {
id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
};
items.push({
key: `ht-${unit.id}`, label: getAmsName(unit.id),
amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
nozzleSide: getNozzleSide(unit.id),
});
}
for (const extTray of vtTrays) {
const extTrayId = extTray.id ?? 254;
items.push({
key: `ext-${extTrayId}`,
label: isDualNozzle
? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
: t('printers.ext', 'Ext'),
amsId: 255, trayId: extTrayId - 254, tray: extTray,
isEmpty: isTrayEmpty(extTray),
nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
});
}
return items;
}, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
if (!isOpen) return null;
const handleAssign = () => {
if (!selectedPrinter || !selectedSlot) return;
assignMutation.mutate({
spool_id: spool.id,
printer_id: selectedPrinter,
ams_id: selectedSlot.amsId,
tray_id: selectedSlot.trayId,
});
};
const amsUnits: AMSUnit[] = printerStatus?.ams ?? [];
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
<div
className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-zinc-100">
<div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className="w-7 h-7 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100 truncate">
{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
</h2>
<button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Spool summary */}
<div className="flex items-center gap-3 p-3 bg-zinc-900/50 rounded-lg mb-5">
<div className="w-8 h-8 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-zinc-200 truncate block">
{spool.color_name || 'Unknown'} &bull; {spool.material}
{spool.subtype && ` ${spool.subtype}`}
<span className="font-normal text-zinc-500 ml-2">
{spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
</span>
<span className="text-xs text-zinc-500">{spool.brand}</span>
</div>
</div>
{/* Printer selector */}
{printers.length > 1 && (
<div className="mb-4">
<select
value={selectedPrinter ?? ''}
onChange={(e) => {
setSelectedPrinter(e.target.value ? Number(e.target.value) : null);
setSelectedSlot(null);
}}
className="w-full px-3 py-2.5 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-zinc-200 min-h-[44px]"
>
<option value="">{t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}</option>
{printers.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
{/* AMS slot grid */}
{selectedPrinter === null ? (
<div className="text-center py-8 text-zinc-500 text-sm">
{t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}
</div>
) : amsUnits.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
{t('spoolbuddy.modal.noAmsDetected', 'No AMS detected on this printer')}
</div>
) : (
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{amsUnits.map((unit) => (
<AmsSlotSelector
key={unit.id}
unit={unit}
selectedSlot={selectedSlot}
onSelectSlot={(trayId) => setSelectedSlot({ amsId: unit.id, trayId })}
/>
))}
</div>
)}
{/* Error message */}
{assignMutation.isError && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400">
{t('spoolbuddy.modal.assignError', 'Failed to assign spool. Please try again.')}
</div>
)}
{/* Action buttons */}
<div className="flex gap-3 mt-5">
<button
onClick={handleAssign}
disabled={!selectedSlot || assignMutation.isPending || showSuccess}
className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
showSuccess
? 'bg-green-600/20 text-green-400'
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
{assignMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin inline-block mr-1.5" />
{t('spoolbuddy.modal.assigning', 'Assigning...')}
</>
) : showSuccess ? (
<>
<Check className="w-4 h-4 inline-block mr-1.5" />
{t('spoolbuddy.modal.assignSuccess', 'Assigned!')}
</>
) : (
t('spoolbuddy.modal.assign', 'Assign')
)}
</button>
<button
onClick={onClose}
className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
>
{t('spoolbuddy.dashboard.close', 'Close')}
</button>
</h2>
</div>
</div>
<button
onClick={onClose}
disabled={isWaiting}
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors shrink-0 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
);
}
// --- AMS Unit slot selector ---
{/* Status message */}
{statusMessage && (
<div className={`mx-5 mt-3 p-3 rounded-lg flex items-center gap-3 border shrink-0 ${
statusType === 'info'
? 'bg-blue-500/10 border-blue-500/40'
: statusType === 'success'
? 'bg-green-500/10 border-green-500/40'
: 'bg-red-500/10 border-red-500/40'
}`}>
{statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-400 animate-spin shrink-0" />}
{statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-400 shrink-0" />}
{statusType === 'error' && <XCircle className="w-4 h-4 text-red-400 shrink-0" />}
<span className={`text-sm ${
statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
}`}>{statusMessage}</span>
</div>
)}
interface AmsSlotSelectorProps {
unit: AMSUnit;
selectedSlot: { amsId: number; trayId: number } | null;
onSelectSlot: (trayId: number) => void;
}
function AmsSlotSelector({ unit, selectedSlot, onSelectSlot }: AmsSlotSelectorProps) {
const { t } = useTranslation();
const trays = unit.tray || [];
const isHt = unit.is_ams_ht;
const slotCount = isHt ? 1 : 4;
return (
<div className="bg-zinc-900/50 rounded-lg p-3">
<div className="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">
{getAmsName(unit.id)}
</div>
<div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px]' : 'grid-cols-4'} gap-2`}>
{Array.from({ length: slotCount }).map((_, i) => {
const tray: AMSTray = trays[i] || {
id: i,
tray_color: null,
tray_type: '',
tray_sub_brands: null,
tray_id_name: null,
tray_info_idx: null,
remain: -1,
k: null,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
};
const isEmpty = isTrayEmpty(tray);
const color = trayColorToCSS(tray.tray_color);
const isSelected = selectedSlot?.amsId === unit.id && selectedSlot.trayId === i;
return (
<button
key={i}
type="button"
onClick={() => onSelectSlot(i)}
className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all min-h-[44px] ${
isSelected
? 'ring-2 ring-green-500 bg-green-500/10'
: 'hover:bg-white/5'
}`}
>
{/* Color circle */}
<div className="relative w-10 h-10 mb-1">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-zinc-600" />
</div>
) : (
<div className="w-full h-full rounded-full" style={{ backgroundColor: color }} />
)}
{/* AMS slots */}
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
{!isConnected && printerId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/50">
<p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
</div>
</div>
) : amsUnits.length === 0 && vtTrays.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/50">
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-lg mb-2">{t('spoolbuddy.ams.noData', 'No AMS detected')}</p>
<p className="text-sm">{t('spoolbuddy.ams.connectAms', 'Connect an AMS to see filament slots')}</p>
</div>
</div>
) : (
<>
{/* Regular AMS — 2-col grid */}
{regularAms.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
{regularAms.map((unit) => (
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={null}
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
isDualNozzle={isDualNozzle}
nozzleSide={getNozzleSide(unit.id)}
/>
))}
</div>
)}
{/* Material */}
<span className="text-xs text-zinc-400 truncate max-w-full">
{isEmpty ? t('spoolbuddy.ams.empty', 'Empty') : tray.tray_type || '?'}
</span>
{/* Single-slot items (HT + External) */}
{singleSlots.length > 0 && (
<div className="flex gap-2 shrink-0">
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
const color = trayColorToCSS(tray.tray_color);
return (
<div
key={key}
onClick={() => handleSlotClick(amsId, trayId)}
className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
isWaiting ? 'opacity-50 pointer-events-none' : ''
}`}
>
<div className="relative w-10 h-10 shrink-0">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
</div>
) : (
<svg viewBox="0 0 56 56" className="w-full h-full">
<circle cx="28" cy="28" r="26" fill={color} />
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
<circle cx="28" cy="28" r="8" fill="#2d2d2d" />
<circle cx="28" cy="28" r="5" fill="#1a1a1a" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1">
<span className="text-xs text-white/50 font-medium">{label}</span>
{nozzleSide && <NozzleBadge side={nozzleSide} />}
</div>
<div className="text-sm text-white/80 truncate">
{isEmpty ? 'Empty' : tray.tray_type || '?'}
</div>
</div>
{!isEmpty && tray.remain != null && tray.remain >= 0 && (
<div className="w-1.5 h-8 bg-bambu-dark-tertiary rounded-full overflow-hidden shrink-0 flex flex-col-reverse">
<div
className="w-full rounded-full"
style={{
height: `${tray.remain}%`,
backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</div>
{/* Slot number */}
<span className="absolute top-0.5 right-1 text-[10px] text-zinc-600">
{t('spoolbuddy.modal.slot', 'Slot')} {i + 1}
</span>
</button>
);
})}
{/* Footer */}
<div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
<button
onClick={onClose}
disabled={isWaiting}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors min-h-[44px] disabled:opacity-50"
>
{statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
</button>
</div>
</div>
);
@@ -78,7 +78,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
{/* Right side indicators */}
<div className="flex items-center gap-3 shrink-0">
{/* WiFi signal bars */}
<div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
<div className="flex items-center" title={deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}>
{deviceOnline ? (
<div className="flex items-end gap-0.5 h-4">
{[1, 2, 3, 4].map((level) => (
@@ -97,7 +97,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
{/* Device LED */}
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded-full ${deviceOnline ? 'bg-bambu-green shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-bambu-gray'}`} />
<span className="text-sm text-white/50">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
<span className="text-sm text-white/50">{deviceOnline ? t('spoolbuddy.status.backend', 'Backend') : t('spoolbuddy.status.offline', 'Offline')}</span>
</div>
{/* Clock */}
@@ -24,12 +24,12 @@ function getDefaultCoreWeight(): number {
interface SpoolInfoCardProps {
spool: MatchedSpool;
scaleWeight: number | null;
weightStable: boolean;
onClose?: () => void;
onSyncWeight?: () => void;
onAssignToAms?: () => void;
}
export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyncWeight }: SpoolInfoCardProps) {
export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {
const { t } = useTranslation();
const [syncing, setSyncing] = useState(false);
const [synced, setSynced] = useState(false);
@@ -66,7 +66,7 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
const handleSyncWeight = async () => {
if (scaleWeight === null || !weightStable) return;
if (scaleWeight === null) return;
setSyncing(true);
try {
await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
@@ -178,13 +178,23 @@ export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose, onSyn
{/* Action buttons */}
<div className="flex gap-2 justify-center">
{onAssignToAms && (
<button
onClick={onAssignToAms}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
>
{t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
</button>
)}
<button
onClick={handleSyncWeight}
disabled={!weightStable || scaleWeight === null || syncing}
disabled={scaleWeight === null || syncing}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
synced
? 'bg-green-600/20 text-green-400'
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
: onAssignToAms
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
{syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
@@ -207,10 +217,11 @@ interface UnknownTagCardProps {
scaleWeight: number | null;
coreWeight?: number;
onLinkSpool?: () => void;
onAddToInventory?: () => void;
onClose?: () => void;
}
export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onClose }: UnknownTagCardProps) {
export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, onAddToInventory, onClose }: UnknownTagCardProps) {
const { t } = useTranslation();
const defaultCoreWeight = coreWeight ?? getDefaultCoreWeight();
const grossWeight = scaleWeight !== null
@@ -240,10 +251,18 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
</div>
)}
<div className="flex flex-wrap gap-2 justify-center">
{onAddToInventory && (
<button
onClick={onAddToInventory}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
>
{t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
</button>
)}
{onLinkSpool && (
<button
onClick={onLinkSpool}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
>
<svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />