Feature/spoolman inventory UI (#1241)

feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
This commit is contained in:
MartinNYHC
2026-05-08 11:52:42 +02:00
committed by GitHub
parent ceffcfaef6
commit b30a283184
112 changed files with 24572 additions and 2230 deletions
@@ -158,6 +158,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
<div
className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
onClick={onClick}
title={onClick ? `AMS Slot ${slotIndex + 1}` : undefined}
>
{/* Spool visualization */}
<div className="relative w-16 h-16 mb-1">
@@ -56,9 +56,10 @@ interface AssignToAmsModalProps {
onClose: () => void;
spool: InventorySpool;
printerId: number | null;
spoolmanMode?: boolean;
}
export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [statusMessage, setStatusMessage] = useState<string | null>(null);
@@ -120,6 +121,17 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
staleTime: 30 * 1000,
});
const { data: spoolmanAssignments = [] } = useQuery({
queryKey: ['spoolman-slot-assignments', printerId],
queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined),
enabled: isOpen && !!spoolmanMode && printerId !== null,
staleTime: 30 * 1000,
});
const currentAssignment = spoolmanMode
? spoolmanAssignments.find(a => a.spoolman_spool_id === spool.id)
: undefined;
// Build fill-level override map from inventory assignments
const fillOverrides = useMemo(() => {
const map: Record<string, number> = {};
@@ -166,17 +178,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
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). When the
// target slot is currently empty, the backend persists the assignment and
// skips the MQTT publish (firmware drops it anyway); on_ams_change re-fires
// the full configuration when filament is later inserted. The response's
// `pending_config` flag distinguishes that from the immediate-apply path
// so we can adjust the success toast.
// Assign spool to AMS slot — single API call, backend handles both DB record
// AND MQTT auto-configuration. When the target slot is currently empty, the
// backend persists the assignment and skips the MQTT publish (firmware drops
// it anyway); on_ams_change re-fires the full configuration when filament is
// later inserted. The response's `pending_config` flag distinguishes that
// from the immediate-apply path so we can adjust the success toast.
const configureMutation = useMutation({
mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
if (!printerId) throw new Error('No printer selected');
if (spoolmanMode) {
return await api.assignSpoolmanSlot({
spoolman_spool_id: spool.id,
printer_id: printerId,
ams_id: amsId,
tray_id: trayId,
});
}
return await api.assignSpool({
spool_id: spool.id,
printer_id: printerId,
@@ -186,7 +205,10 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
},
onSuccess: (assignment) => {
setStatusType('success');
if (assignment?.pending_config) {
// pending_config only exists on SpoolAssignment (the local-inventory path);
// the Spoolman path returns InventorySpool which always implies immediate apply.
const pendingConfig = assignment && 'pending_config' in assignment && assignment.pending_config;
if (pendingConfig) {
setStatusMessage(
t(
'spoolbuddy.modal.assignPendingInsert',
@@ -197,7 +219,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
}
queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
setTimeout(() => onClose(), assignment?.pending_config ? 2500 : 1500);
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
},
onError: (err) => {
setStatusType('error');
@@ -398,7 +422,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
<AmsUnitCard
key={unit.id}
unit={unit}
activeSlot={null}
activeSlot={currentAssignment?.ams_id === unit.id ? (currentAssignment.tray_id ?? null) : null}
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
isDualNozzle={isDualNozzle}
nozzleSide={getNozzleSide(unit.id)}
@@ -414,13 +438,16 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
<div className="flex gap-2 shrink-0">
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
const color = trayColorToCSS(tray.tray_color);
const isActive = !!currentAssignment &&
currentAssignment.ams_id === amsId &&
currentAssignment.tray_id === trayId;
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' : ''
}`}
isActive ? 'ring-2 ring-bambu-green' : ''
} ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
>
<div className="relative w-10 h-10 shrink-0">
{isEmpty ? (
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
import type { InventorySpool } from '../../api/client';
import { spoolbuddyApi, api } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
@@ -28,6 +28,8 @@ interface InventorySpoolInfoCardProps {
onClose?: () => void;
onSyncWeight?: () => void;
onAssignToAms?: () => void;
isAssigned?: boolean;
onUnassignFromAms?: () => void;
className?: string;
}
@@ -38,6 +40,8 @@ export function InventorySpoolInfoCard({
onClose,
onSyncWeight,
onAssignToAms,
isAssigned,
onUnassignFromAms,
className,
}: InventorySpoolInfoCardProps) {
const { t } = useTranslation();
@@ -250,12 +254,22 @@ export function InventorySpoolInfoCard({
<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]"
onClick={isAssigned ? undefined : onAssignToAms}
disabled={!!isAssigned}
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] disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
</button>
)}
{onUnassignFromAms && (
<button
onClick={onUnassignFromAms}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors min-h-[44px]"
>
<Unlink className="w-4 h-4 inline mr-1" />
{t('inventory.unassignSpool')}
</button>
)}
<button
onClick={handleSyncWeight}
disabled={liveScaleWeight === null || syncing}
@@ -108,7 +108,7 @@ export function SpoolBuddyLayout() {
// Blanking itself is handled by swayidle/wlopm at the OS level on the kiosk device —
// when the HDMI output powers off and the user taps the screen, labwc delivers the
// input event to swayidle's `resume` command which re-powers HDMI. See issue #937.
const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid || sbState.unknownTrayUuid);
const prevTagDetected = useRef(false);
useEffect(() => {
if (tagDetected && !prevTagDetected.current) {
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { spoolbuddyApi } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
@@ -27,9 +27,11 @@ interface SpoolInfoCardProps {
onClose?: () => void;
onSyncWeight?: () => void;
onAssignToAms?: () => void;
isAssigned?: boolean;
onUnassignFromAms?: () => void;
}
export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {
export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms, isAssigned, onUnassignFromAms }: SpoolInfoCardProps) {
const { t } = useTranslation();
const [syncing, setSyncing] = useState(false);
const [synced, setSynced] = useState(false);
@@ -180,12 +182,22 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
<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]"
onClick={isAssigned ? undefined : onAssignToAms}
disabled={!!isAssigned}
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] disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
</button>
)}
{onUnassignFromAms && (
<button
onClick={onUnassignFromAms}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors min-h-[44px]"
>
<Unlink className="w-4 h-4 inline mr-1" />
{t('inventory.unassignSpool')}
</button>
)}
<button
onClick={handleSyncWeight}
disabled={scaleWeight === null || syncing}
@@ -267,7 +279,7 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
<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" />
</svg>
{t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
{t('inventory.assignSpool', 'Assign Spool')}
</button>
)}
{onClose && (
@@ -347,7 +347,7 @@ function UnknownTagView({ tagUid, scaleWeight, onAddToInventory, onLinkSpool, on
onClick={onLinkSpool}
className="flex-1 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.linkSpool', 'Link to Spool')}
{t('inventory.assignSpool', 'Assign Spool')}
</button>
)}
<button