mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
[Feature] Spoolbuddy spool detail card and UI improvements (#866)
[Feature] Spoolbuddy spool detail card and UI improvements (#866)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import type { InventorySpool } from '../../api/client';
|
||||
import { spoolbuddyApi, api } from '../../api/client';
|
||||
import { SpoolIcon } from './SpoolIcon';
|
||||
|
||||
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
|
||||
|
||||
function getDefaultCoreWeight(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
|
||||
if (stored) {
|
||||
const weight = parseInt(stored, 10);
|
||||
if (weight >= 0 && weight <= 500) return weight;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return 250;
|
||||
}
|
||||
|
||||
interface InventorySpoolInfoCardProps {
|
||||
spool: InventorySpool;
|
||||
liveScaleWeight: number | null;
|
||||
persistedGrossWeight?: number | null;
|
||||
onClose?: () => void;
|
||||
onSyncWeight?: () => void;
|
||||
onAssignToAms?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InventorySpoolInfoCard({
|
||||
spool,
|
||||
liveScaleWeight,
|
||||
persistedGrossWeight,
|
||||
onClose,
|
||||
onSyncWeight,
|
||||
onAssignToAms,
|
||||
className,
|
||||
}: InventorySpoolInfoCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [synced, setSynced] = useState(false);
|
||||
const [syncedGrossWeight, setSyncedGrossWeight] = useState<number | null>(null);
|
||||
|
||||
// Fetch k_profiles if not already present in the spool object
|
||||
const { data: fetchedKProfiles } = useQuery({
|
||||
queryKey: ['spool-k-profiles', spool.id],
|
||||
queryFn: () => api.getSpoolKProfiles(spool.id),
|
||||
// Inventory list payloads may omit k_profiles, so lazily fetch when missing.
|
||||
enabled: !spool.k_profiles || spool.k_profiles.length === 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Use fetched k_profiles if available, otherwise use the ones from the spool object
|
||||
const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
|
||||
|
||||
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
||||
|
||||
const coreWeight = (spool.core_weight && spool.core_weight > 0)
|
||||
? spool.core_weight
|
||||
: getDefaultCoreWeight();
|
||||
|
||||
const grossWeightFromScale = liveScaleWeight !== null
|
||||
? Math.round(Math.max(0, liveScaleWeight))
|
||||
: null;
|
||||
|
||||
// Inventory scenario: prefer the most recently synced value in this modal session.
|
||||
const displayedGrossWeight = syncedGrossWeight ?? (
|
||||
persistedGrossWeight !== undefined
|
||||
? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null)
|
||||
: grossWeightFromScale
|
||||
);
|
||||
|
||||
const inventoryRemaining = Math.round(Math.max(0,
|
||||
(spool.label_weight || 0) - (spool.weight_used || 0)
|
||||
));
|
||||
|
||||
// Use live scale for remaining/fill only when scale has a meaningful reading.
|
||||
const minDynamicScaleReading = 10;
|
||||
const useDynamicRemaining = grossWeightFromScale !== null
|
||||
&& grossWeightFromScale >= minDynamicScaleReading;
|
||||
|
||||
const remaining = useDynamicRemaining
|
||||
? Math.round(Math.max(0, grossWeightFromScale - coreWeight))
|
||||
: inventoryRemaining;
|
||||
|
||||
const labelWeight = Math.round(spool.label_weight || 1000);
|
||||
const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
|
||||
const fillColor = fillPercent !== null
|
||||
? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444')
|
||||
: '#808080';
|
||||
|
||||
const netWeight = Math.max(0,
|
||||
(spool.label_weight || 0) - (spool.weight_used || 0)
|
||||
);
|
||||
const calculatedWeight = netWeight + coreWeight;
|
||||
const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null;
|
||||
const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
|
||||
|
||||
// Inventory fallback so gross is always populated across spools.
|
||||
const inventoryDerivedGrossWeight = Math.round(calculatedWeight);
|
||||
const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;
|
||||
const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)
|
||||
? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C`
|
||||
: null;
|
||||
const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null;
|
||||
const note = spool.note?.trim() || null;
|
||||
const kFactorSummary = (kProfiles && kProfiles.length > 0)
|
||||
? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ')
|
||||
: null;
|
||||
|
||||
const handleSyncWeight = async () => {
|
||||
if (liveScaleWeight === null) return;
|
||||
const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight));
|
||||
setSyncing(true);
|
||||
try {
|
||||
await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight);
|
||||
setSyncedGrossWeight(roundedLiveWeight);
|
||||
setSynced(true);
|
||||
onSyncWeight?.();
|
||||
setTimeout(() => setSynced(false), 3000);
|
||||
} catch (e) {
|
||||
console.error('Failed to sync weight:', e);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="relative shrink-0">
|
||||
<SpoolIcon color={colorHex} isEmpty={false} size={100} />
|
||||
{fillPercent !== null && (
|
||||
<div
|
||||
className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
|
||||
style={{ backgroundColor: fillColor }}
|
||||
>
|
||||
{fillPercent}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pt-1">
|
||||
<h3 className="text-lg font-semibold text-zinc-100">
|
||||
{spool.color_name || 'Unknown color'}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{spool.brand} • {spool.material}
|
||||
{spool.subtype && ` ${spool.subtype}`}
|
||||
</p>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
|
||||
<span className="text-sm text-zinc-500">/ {labelWeight}g</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
|
||||
|
||||
<div className="mt-2 max-w-xs">
|
||||
<div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${fillPercent ?? 0}%`, backgroundColor: fillColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
|
||||
<span className="font-mono text-zinc-300">{resolvedGrossWeight}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
|
||||
<span className="font-mono text-zinc-300">{coreWeight}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
|
||||
<span className="font-mono text-zinc-300">{labelWeight}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
|
||||
{grossWeightFromScale !== null ? (
|
||||
<span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
|
||||
{grossWeightFromScale}g
|
||||
{isMatch ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<button
|
||||
onClick={handleSyncWeight}
|
||||
className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
|
||||
title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-500">{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
|
||||
<span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
|
||||
{spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
{nozzleTempRange && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')}</span>
|
||||
<span className="font-mono text-zinc-300">{nozzleTempRange}</span>
|
||||
</div>
|
||||
)}
|
||||
{spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
|
||||
<span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
|
||||
</div>
|
||||
)}
|
||||
{kFactorSummary && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-zinc-500">{t('spoolbuddy.inventory.kProfiles', 'K-Profile')}</span>
|
||||
<span className="font-mono text-zinc-300 truncate max-w-[220px] text-right" title={kFactorSummary}>{kFactorSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
{slicerPreset && (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}</p>
|
||||
<p className="text-sm text-zinc-300 whitespace-pre-wrap break-words">{slicerPreset}</p>
|
||||
</div>
|
||||
)}
|
||||
{note && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
|
||||
<p className="text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1">{note}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={liveScaleWeight === 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'
|
||||
: 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')}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
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]"
|
||||
>
|
||||
{t('spoolbuddy.dashboard.close', 'Close')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -387,6 +387,43 @@ body {
|
||||
animation: slide-in-left 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* SpoolBuddy idle scan animation: transform/opacity only for low-power devices */
|
||||
@keyframes spoolbuddy-optimized-ping {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spoolbuddy-optimized-ping {
|
||||
will-change: auto;
|
||||
contain: layout;
|
||||
animation: spoolbuddy-optimized-ping 3.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.spoolbuddy-spool-glow {
|
||||
transform: scale(2.0);
|
||||
will-change: auto;
|
||||
transition: background 140ms linear, opacity 140ms linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spoolbuddy-optimized-ping {
|
||||
animation: none;
|
||||
opacity: 0.25;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.spoolbuddy-spool-glow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card shadows - uses theme-specific shadow */
|
||||
.card-shadow {
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
@@ -46,16 +46,36 @@ function IdleSpool() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* Spool with single subtle NFC ring */}
|
||||
{/* Animated spool with optimized NFC waves */}
|
||||
<div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
|
||||
{/* Static NFC wave rings */}
|
||||
<div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30" />
|
||||
<div className="absolute w-32 h-32 rounded-full border border-green-500/20" />
|
||||
<div className="absolute w-40 h-40 rounded-full border border-green-500/10" />
|
||||
{/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full border spoolbuddy-optimized-ping"
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderColor: `${currentColor}4D`,
|
||||
transition: 'border-color 140ms linear',
|
||||
animationDelay: `${i * 0.8}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Spool icon with slow color transition */}
|
||||
<div className="relative transition-colors duration-[2000ms]">
|
||||
<SpoolIcon color={currentColor} isEmpty={false} size={100} />
|
||||
{/* Spool icon with lightweight radial glow */}
|
||||
<div className="relative overflow-hidden rounded-full">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${currentColor} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative" style={{ transition: 'opacity 140ms linear' }}>
|
||||
<SpoolIcon color={currentColor} isEmpty={false} size={100} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Search, X, Package } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { InventorySpool, SpoolAssignment } from '../../api/client';
|
||||
import { resolveSpoolColorName } from '../../utils/colors';
|
||||
import { formatSlotLabel } from '../../utils/amsHelpers';
|
||||
import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
|
||||
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
|
||||
type FilterMode = 'all' | 'in_ams' | string; // string = material name
|
||||
|
||||
@@ -49,10 +53,12 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
|
||||
}
|
||||
|
||||
export function SpoolBuddyInventoryPage() {
|
||||
const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
|
||||
const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
|
||||
|
||||
const { data: spoolmanSettings } = useQuery({
|
||||
queryKey: ['spoolman-settings'],
|
||||
@@ -60,7 +66,7 @@ export function SpoolBuddyInventoryPage() {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: spools = [], isLoading } = useQuery({
|
||||
const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(false),
|
||||
refetchInterval: 30000,
|
||||
@@ -220,12 +226,29 @@ export function SpoolBuddyInventoryPage() {
|
||||
{selectedSpoolId != null && (() => {
|
||||
const liveSpool = spools.find(s => s.id === selectedSpoolId);
|
||||
if (!liveSpool) return null;
|
||||
const handleCloseDetail = () => {
|
||||
setSelectedSpoolId(null);
|
||||
setShowAssignAmsModal(false);
|
||||
};
|
||||
return (
|
||||
<SpoolDetailModal
|
||||
spool={liveSpool}
|
||||
assignment={assignmentMap[liveSpool.id]}
|
||||
onClose={() => setSelectedSpoolId(null)}
|
||||
/>
|
||||
<>
|
||||
<SpoolDetailModal
|
||||
spool={liveSpool}
|
||||
assignment={assignmentMap[liveSpool.id]}
|
||||
sbState={sbState}
|
||||
onSyncWeight={() => {
|
||||
void refetchSpools();
|
||||
}}
|
||||
onAssignToAms={() => setShowAssignAmsModal(true)}
|
||||
onClose={handleCloseDetail}
|
||||
/>
|
||||
<AssignToAmsModal
|
||||
isOpen={showAssignAmsModal}
|
||||
onClose={() => setShowAssignAmsModal(false)}
|
||||
spool={liveSpool}
|
||||
printerId={selectedPrinterId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -314,168 +337,51 @@ function CatalogCard({ spool, assignment, onClick }: {
|
||||
}
|
||||
|
||||
/* Detail bottom sheet */
|
||||
function SpoolDetailModal({ spool, assignment, onClose }: {
|
||||
function SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: {
|
||||
spool: InventorySpool;
|
||||
assignment?: SpoolAssignment;
|
||||
sbState: SpoolBuddyOutletContext['sbState'];
|
||||
onSyncWeight: () => void;
|
||||
onAssignToAms: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const color = spoolColor(spool);
|
||||
const pct = spoolPct(spool);
|
||||
const remaining = spoolRemaining(spool);
|
||||
const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
|
||||
const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null;
|
||||
const modalScaleWeight = useLiveScaleWeight
|
||||
? Math.round(sbState.weight as number)
|
||||
: null;
|
||||
const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div
|
||||
className="h-full w-full bg-bambu-dark overflow-y-auto"
|
||||
className="w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with spool icon */}
|
||||
<div className="flex items-center gap-4 p-4 pb-3">
|
||||
<SpoolCircle color={color} size={72} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{spoolDisplayName(spool)}
|
||||
</h2>
|
||||
{spool.brand && (
|
||||
<p className="text-sm text-white/50">{spool.brand}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-white/10"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-white/60">
|
||||
{colorName || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Remaining bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-white/50 mb-1.5">
|
||||
<span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
|
||||
<span>{Math.round(remaining)}g ({Math.round(pct)}%)</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AMS location */}
|
||||
<div className="space-y-3">
|
||||
{assignment && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
|
||||
{assignmentLabel(assignment)}
|
||||
</span>
|
||||
{assignment.printer_name && (
|
||||
<span className="text-xs text-white/40">{assignment.printer_name}</span>
|
||||
<span className="text-xs text-zinc-400">{assignment.printer_name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail grid */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
|
||||
value={`${spool.label_weight}g`}
|
||||
<div className="flex justify-center">
|
||||
<InventorySpoolInfoCard
|
||||
spool={spool}
|
||||
liveScaleWeight={modalScaleWeight}
|
||||
persistedGrossWeight={persistedGrossWeight}
|
||||
onSyncWeight={onSyncWeight}
|
||||
onAssignToAms={onAssignToAms}
|
||||
onClose={onClose}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.weightUsed', 'Used')}
|
||||
value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
|
||||
/>
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
|
||||
value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
|
||||
/>
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
|
||||
value={`${spool.label_weight + spool.core_weight}g`}
|
||||
/>
|
||||
{spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
|
||||
value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
|
||||
/>
|
||||
)}
|
||||
{spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
|
||||
value={`${spool.cost_per_kg.toFixed(2)}/kg`}
|
||||
/>
|
||||
)}
|
||||
{spool.last_scale_weight != null && (
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
|
||||
value={`${Math.round(spool.last_scale_weight)}g`}
|
||||
/>
|
||||
)}
|
||||
{spool.tag_uid && (
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.tagId', 'Tag')}
|
||||
value={spool.tag_uid}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{(spool.slicer_filament_name || spool.slicer_filament) && (
|
||||
<DetailItem
|
||||
label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
|
||||
value={spool.slicer_filament_name || spool.slicer_filament || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* K-Profiles */}
|
||||
{spool.k_profiles && spool.k_profiles.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
|
||||
<div className="space-y-1">
|
||||
{spool.k_profiles.map(kp => (
|
||||
<div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
|
||||
<span className="text-sm text-white/70 truncate">
|
||||
{kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
|
||||
{kp.k_value.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{spool.note && (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-3">
|
||||
<p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
|
||||
<p className="text-sm text-white/70">{spool.note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t('spoolbuddy.inventory.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
|
||||
<p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
|
||||
<p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user