mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user