mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
@@ -421,9 +421,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</p>
|
||||
<span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{spool.rgba && (
|
||||
<span
|
||||
@@ -503,9 +506,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
||||
</p>
|
||||
<span className="text-[10px] font-mono text-bambu-gray shrink-0">#{spool.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{spool.rgba && (
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Link2, Unlink } from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -25,6 +25,7 @@ import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
||||
import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
|
||||
import { normaliseFlow } from '../utils/nozzleFlow';
|
||||
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
||||
import { AssignToAmsModal } from './spoolbuddy/AssignToAmsModal';
|
||||
import {
|
||||
invalidateInventoryLocations,
|
||||
invalidateSpoolAndLocationQueries,
|
||||
@@ -91,6 +92,11 @@ export function SpoolFormModal({
|
||||
const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
|
||||
const [quickAdd, setQuickAdd] = useState(false);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAssignModal(false);
|
||||
}, [isOpen, spool?.id]);
|
||||
|
||||
// Cloud presets
|
||||
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
||||
@@ -729,12 +735,12 @@ export function SpoolFormModal({
|
||||
// the slot assignment lives in the spoolman_slot_assignments table keyed by
|
||||
// spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
|
||||
// resulting "Unassign button is always disabled" report.
|
||||
const { data: assignments } = useQuery({
|
||||
const { data: assignments, isLoading: assignmentsLoading } = useQuery({
|
||||
queryKey: ['spool-assignments'],
|
||||
queryFn: () => api.getAssignments(),
|
||||
enabled: isOpen && isEditing && !spoolmanMode,
|
||||
});
|
||||
const { data: spoolmanSlotAssignments } = useQuery({
|
||||
const { data: spoolmanSlotAssignments, isLoading: spoolmanAssignmentsLoading } = useQuery({
|
||||
queryKey: ['spoolman-slot-assignments-all'],
|
||||
queryFn: () => api.getSpoolmanSlotAssignments(),
|
||||
enabled: isOpen && isEditing && spoolmanMode,
|
||||
@@ -746,6 +752,7 @@ export function SpoolFormModal({
|
||||
}
|
||||
return assignments?.find(a => a.spool_id === spool.id);
|
||||
})();
|
||||
const assignmentLoading = spoolmanMode ? spoolmanAssignmentsLoading : assignmentsLoading;
|
||||
|
||||
// Read inventory + settings caches (already populated by InventoryPage) to
|
||||
// drive the category autocomplete and low-stock-threshold placeholder. #729
|
||||
@@ -910,11 +917,14 @@ export function SpoolFormModal({
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
// The nested AMS assignment dialog owns Escape while it is open. Both
|
||||
// listeners live on document, so propagation control in the child would
|
||||
// not prevent this editor handler from running for the same key press.
|
||||
if (e.key === 'Escape' && !showAssignModal) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen, onClose, showAssignModal]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -985,6 +995,7 @@ export function SpoolFormModal({
|
||||
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
@@ -1242,14 +1253,25 @@ export function SpoolFormModal({
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('inventory.clearRfid', 'Clear RFID Tag')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unassignMutation.mutate()}
|
||||
disabled={isPending || !spoolAssignment}
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
{t('inventory.unassignSpool', 'Unassign')}
|
||||
</Button>
|
||||
{spoolAssignment ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unassignMutation.mutate()}
|
||||
disabled={isPending || assignmentLoading}
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
{t('inventory.unassignSpool', 'Unassign')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowAssignModal(true)}
|
||||
disabled={isPending || assignmentLoading}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('inventory.assignSpool', 'Assign Spool')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
@@ -1276,5 +1298,16 @@ export function SpoolFormModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing && spool && (
|
||||
<AssignToAmsModal
|
||||
isOpen={showAssignModal}
|
||||
onClose={() => setShowAssignModal(false)}
|
||||
spool={spool}
|
||||
printerId={null}
|
||||
variant="dialog"
|
||||
spoolmanMode={spoolmanMode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AMSUnit, AMSTray } from '../../api/client';
|
||||
import { getFillBarColor } from '../../utils/amsHelpers';
|
||||
|
||||
@@ -155,10 +156,11 @@ interface SpoolSlotProps {
|
||||
isActive: boolean;
|
||||
fillOverride?: number | null;
|
||||
spoolmanFill?: number | null;
|
||||
accessibleLabel?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onClick }: SpoolSlotProps) {
|
||||
function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, accessibleLabel, onClick }: SpoolSlotProps) {
|
||||
const isEmpty = isTrayEmpty(tray);
|
||||
const emptyKind = getEmptySlotKind(tray);
|
||||
const color = trayColorToCSS(tray.tray_color);
|
||||
@@ -170,9 +172,24 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
|
||||
|
||||
return (
|
||||
<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' : ''}`}
|
||||
className={`relative flex flex-col items-center p-2.5 rounded-lg border transition-all ${
|
||||
isActive
|
||||
? 'ring-2 ring-bambu-green border-bambu-green bg-bambu-green/10'
|
||||
: 'border-transparent'
|
||||
} ${onClick ? 'cursor-pointer hover:border-bambu-gray hover:bg-bambu-dark-tertiary' : ''}`}
|
||||
onClick={onClick}
|
||||
title={onClick ? `AMS Slot ${slotIndex + 1}` : undefined}
|
||||
onKeyDown={onClick ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
} : undefined}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
aria-pressed={onClick ? isActive : undefined}
|
||||
aria-label={onClick ? accessibleLabel : undefined}
|
||||
title={onClick ? accessibleLabel : undefined}
|
||||
data-testid={onClick ? 'ams-slot' : undefined}
|
||||
>
|
||||
{/* Spool visualization */}
|
||||
<div className="relative w-16 h-16 mb-1">
|
||||
@@ -196,7 +213,7 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
|
||||
|
||||
{/* Material type */}
|
||||
<span
|
||||
className="text-sm text-white/70 truncate max-w-full"
|
||||
className="text-sm text-bambu-gray-light truncate max-w-full"
|
||||
title={emptyKind === 'reset' ? 'Spool loaded — slot not configured' : undefined}
|
||||
>
|
||||
{isEmpty ? (emptyKind === 'reset' ? '?' : 'Empty') : tray.tray_type || 'Unknown'}
|
||||
@@ -216,7 +233,9 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
|
||||
)}
|
||||
|
||||
{/* Slot number */}
|
||||
<span className="absolute top-1 right-1 text-xs text-white/30">{slotIndex + 1}</span>
|
||||
<span className="absolute top-1 right-1 rounded bg-bambu-dark-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-bambu-gray-light">
|
||||
{slotIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,16 +259,18 @@ interface AmsUnitCardProps {
|
||||
}
|
||||
|
||||
export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds, fillOverrides, spoolmanFillOverrides }: AmsUnitCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const trays = unit.tray || [];
|
||||
const isHt = unit.is_ams_ht;
|
||||
const slotCount = isHt ? 1 : 4;
|
||||
const amsName = getAmsName(unit.id);
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-3">
|
||||
<div className="bg-bambu-dark-secondary rounded-xl border border-[var(--border-color)] p-3 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-white font-medium text-base">{getAmsName(unit.id)}</span>
|
||||
<span className="text-white font-medium text-base">{amsName}</span>
|
||||
{isDualNozzle && nozzleSide && (
|
||||
<NozzleBadge side={nozzleSide} />
|
||||
)}
|
||||
@@ -298,6 +319,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
|
||||
isActive={activeSlot === i}
|
||||
fillOverride={fillOverrides?.[`${unit.id}-${i}`] ?? null}
|
||||
spoolmanFill={spoolmanFillOverrides?.[`${unit.id}-${i}`] ?? null}
|
||||
accessibleLabel={t('configureAmsSlot.slotLabel', { ams: amsName, slot: i + 1 })}
|
||||
onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
|
||||
import { X, Loader2, Check, CheckCircle, XCircle, Layers } from 'lucide-react';
|
||||
import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
|
||||
import { ConfirmModal } from '../ConfirmModal';
|
||||
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
|
||||
@@ -25,6 +25,20 @@ function trayColorToCSS(color: string | null): string {
|
||||
return `#${color.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||
.filter((element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true');
|
||||
}
|
||||
|
||||
// --- Material/profile mismatch helpers (pure functions, no component state) ---
|
||||
const normalizeValue = (value: string | undefined | null) =>
|
||||
(value ?? '').trim().toUpperCase();
|
||||
@@ -58,12 +72,23 @@ interface AssignToAmsModalProps {
|
||||
onClose: () => void;
|
||||
spool: InventorySpool;
|
||||
printerId: number | null;
|
||||
variant?: 'dialog' | 'kiosk';
|
||||
spoolmanMode?: boolean;
|
||||
}
|
||||
|
||||
export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) {
|
||||
export function AssignToAmsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
spool,
|
||||
printerId,
|
||||
variant = 'dialog',
|
||||
spoolmanMode = false,
|
||||
}: AssignToAmsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(printerId);
|
||||
const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
|
||||
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
|
||||
@@ -84,34 +109,100 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedPrinterId(printerId);
|
||||
setSelectedSlot(null);
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
setShowMismatchConfirm(false);
|
||||
setMismatchDetails(null);
|
||||
setPendingSlot(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, printerId, spool.id]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
// Printer selection and visual presentation are separate concerns: the
|
||||
// SpoolBuddy kiosk must stay full-screen even when no printer is online and
|
||||
// the picker is shown.
|
||||
const showPrinterPicker = printerId === null;
|
||||
const requiresSlotConfirmation = showPrinterPicker;
|
||||
const targetPrinterId = printerId ?? selectedPrinterId;
|
||||
const { data: selectablePrinters = [], isLoading: printersLoading } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => api.getPrinters(),
|
||||
enabled: isOpen && showPrinterPicker,
|
||||
});
|
||||
const activePrinters = useMemo(
|
||||
() => selectablePrinters.filter((candidate) => candidate.is_active !== false),
|
||||
[selectablePrinters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
||||
if (!isOpen) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const dialog = dialogRef.current;
|
||||
const firstFocusable = dialog ? getFocusableElements(dialog)[0] : null;
|
||||
(firstFocusable ?? dialog)?.focus();
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
// ConfirmModal is the active modal while a material warning is visible.
|
||||
// Let it own Escape and keyboard focus until it closes.
|
||||
if (showMismatchConfirm) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = getFocusableElements(dialog);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !dialog.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, [onClose, showMismatchConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
const { data: status } = useQuery<PrinterStatus>({
|
||||
queryKey: ['printerStatus', printerId],
|
||||
queryFn: () => api.getPrinterStatus(printerId!),
|
||||
enabled: isOpen && printerId !== null,
|
||||
const { data: status, isLoading: statusLoading } = useQuery<PrinterStatus>({
|
||||
queryKey: ['printerStatus', targetPrinterId],
|
||||
queryFn: () => api.getPrinterStatus(targetPrinterId!),
|
||||
enabled: isOpen && targetPrinterId !== null,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: printer } = useQuery({
|
||||
queryKey: ['printer', printerId],
|
||||
queryFn: () => api.getPrinter(printerId!),
|
||||
enabled: isOpen && printerId !== null,
|
||||
queryKey: ['printer', targetPrinterId],
|
||||
queryFn: () => api.getPrinter(targetPrinterId!),
|
||||
enabled: isOpen && targetPrinterId !== null,
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
@@ -122,16 +213,16 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
});
|
||||
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments', printerId],
|
||||
queryFn: () => api.getAssignments(printerId!),
|
||||
enabled: isOpen && printerId !== null,
|
||||
queryKey: ['spool-assignments', targetPrinterId],
|
||||
queryFn: () => api.getAssignments(targetPrinterId!),
|
||||
enabled: isOpen && targetPrinterId !== null,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const { data: spoolmanAssignments = [] } = useQuery({
|
||||
queryKey: ['spoolman-slot-assignments', printerId],
|
||||
queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined),
|
||||
enabled: isOpen && !!spoolmanMode && printerId !== null,
|
||||
queryKey: ['spoolman-slot-assignments', targetPrinterId],
|
||||
queryFn: () => api.getSpoolmanSlotAssignments(targetPrinterId ?? undefined),
|
||||
enabled: isOpen && !!spoolmanMode && targetPrinterId !== null,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
@@ -168,6 +259,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
|
||||
|
||||
const cachedAmsExtruderMap = useRef<Record<string, number>>({});
|
||||
useEffect(() => {
|
||||
cachedAmsExtruderMap.current = {};
|
||||
}, [targetPrinterId]);
|
||||
useEffect(() => {
|
||||
if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
|
||||
cachedAmsExtruderMap.current = status.ams_extruder_map;
|
||||
@@ -201,19 +295,19 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
// 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 (!targetPrinterId) throw new Error('No printer selected');
|
||||
|
||||
if (spoolmanMode) {
|
||||
return await api.assignSpoolmanSlot({
|
||||
spoolman_spool_id: spool.id,
|
||||
printer_id: printerId,
|
||||
printer_id: targetPrinterId,
|
||||
ams_id: amsId,
|
||||
tray_id: trayId,
|
||||
});
|
||||
}
|
||||
return await api.assignSpool({
|
||||
spool_id: spool.id,
|
||||
printer_id: printerId,
|
||||
printer_id: targetPrinterId,
|
||||
ams_id: amsId,
|
||||
tray_id: trayId,
|
||||
});
|
||||
@@ -234,8 +328,11 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
|
||||
setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -258,8 +355,11 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
|
||||
if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
|
||||
if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
|
||||
if (amsId === 254) return t('printers.extL', 'Ext-L');
|
||||
return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');
|
||||
if (!isDualNozzle) return t('printers.ext', 'Ext');
|
||||
// Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R;
|
||||
// AMS 254 is retained for compatibility with older assignment records.
|
||||
if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
|
||||
return t('printers.extR', 'Ext-R');
|
||||
}, [t, isDualNozzle]);
|
||||
|
||||
const doAssign = useCallback((amsId: number, trayId: number) => {
|
||||
@@ -268,9 +368,8 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
configureMutation.mutate({ amsId, trayId });
|
||||
}, [configureMutation, t]);
|
||||
|
||||
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
|
||||
const prepareAssignment = useCallback((amsId: number, trayId: number) => {
|
||||
if (isWaiting) return;
|
||||
|
||||
if (!settings?.disable_filament_warnings) {
|
||||
const tray = getTrayForSlot(amsId, trayId);
|
||||
if (tray && !isTrayEmpty(tray)) {
|
||||
@@ -311,6 +410,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
doAssign(amsId, trayId);
|
||||
}, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
|
||||
|
||||
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
|
||||
if (isWaiting) return;
|
||||
|
||||
if (requiresSlotConfirmation) {
|
||||
setSelectedSlot({ amsId, trayId });
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
prepareAssignment(amsId, trayId);
|
||||
}, [requiresSlotConfirmation, isWaiting, prepareAssignment]);
|
||||
|
||||
const handleAssignSelectedSlot = useCallback(() => {
|
||||
if (!selectedSlot) return;
|
||||
prepareAssignment(selectedSlot.amsId, selectedSlot.trayId);
|
||||
}, [prepareAssignment, selectedSlot]);
|
||||
|
||||
const handleConfirmMismatch = useCallback(() => {
|
||||
if (!pendingSlot) return;
|
||||
setShowMismatchConfirm(false);
|
||||
@@ -368,160 +485,270 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
if (!isOpen) return null;
|
||||
|
||||
const colorStyle = spoolSwatchStyle(spool) ?? getSwatchStyle(spool.rgba);
|
||||
const selectedSlotLabel = selectedSlot
|
||||
? getSlotLocationLabel(selectedSlot.amsId, selectedSlot.trayId)
|
||||
: null;
|
||||
const isDialogVariant = variant === 'dialog';
|
||||
const overlayClasses = isDialogVariant
|
||||
? 'fixed inset-0 z-[60] flex items-center justify-center bg-black/45 dark:bg-black/70 p-2 sm:p-4 backdrop-blur-sm animate-fade-in'
|
||||
: 'fixed inset-0 z-[60] bg-bambu-dark';
|
||||
const dialogClasses = isDialogVariant
|
||||
? 'bg-bambu-dark-secondary text-white font-sans w-full max-w-3xl h-[calc(100vh-1rem)] sm:h-[calc(100vh-2rem)] max-h-[820px] border border-[var(--border-color)] rounded-xl sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-slide-up'
|
||||
: 'w-full h-full bg-bambu-dark flex flex-col';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
|
||||
<div
|
||||
className={overlayClasses}
|
||||
onClick={isDialogVariant && !isWaiting ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="assign-to-ams-title"
|
||||
tabIndex={-1}
|
||||
className={dialogClasses}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
|
||||
<div className="flex items-center justify-between bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-7 h-7 rounded-full shrink-0" style={colorStyle} />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-zinc-100 truncate">
|
||||
<h2 id="assign-to-ams-title" className="text-sm font-semibold text-white truncate">
|
||||
{t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
|
||||
<span className="font-normal text-zinc-500 ml-2">
|
||||
<span className="font-normal text-bambu-gray-light ml-2">
|
||||
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) ||
|
||||
t('spoolbuddy.spool.unknownColor')}{' '}
|
||||
• {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
|
||||
t('spoolbuddy.spool.unknownColor')} • {spool.brand} {spool.material}
|
||||
{spool.subtype && ` ${spool.subtype}`}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-zinc-500 ml-2 shrink-0">#{spool.id}</span>
|
||||
<span className="text-[10px] font-mono text-bambu-gray ml-2 shrink-0">#{spool.id}</span>
|
||||
</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"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPrinterPicker && (
|
||||
<div className="bg-bambu-dark-secondary px-5 py-3 border-b border-[var(--border-color)] shrink-0">
|
||||
<label htmlFor="assign-spool-printer" className="flex items-center gap-2 text-sm font-medium text-white mb-2">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">1</span>
|
||||
{t('printModal.selectPrinter')}
|
||||
</label>
|
||||
<select
|
||||
id="assign-spool-printer"
|
||||
aria-label={t('printModal.selectPrinter')}
|
||||
value={selectedPrinterId ?? ''}
|
||||
onChange={(event) => {
|
||||
setSelectedPrinterId(event.target.value ? Number(event.target.value) : null);
|
||||
setSelectedSlot(null);
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
}}
|
||||
disabled={isWaiting || printersLoading}
|
||||
className="w-full bg-bambu-dark border border-[var(--border-color)] rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-bambu-green disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{printersLoading
|
||||
? t('common.loading')
|
||||
: activePrinters.length === 0
|
||||
? t('common.noPrinters')
|
||||
: t('printModal.selectPrinter')}
|
||||
</option>
|
||||
{activePrinters.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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'
|
||||
? 'bg-blue-50 border-blue-300 dark:bg-blue-500/10 dark:border-blue-500/40'
|
||||
: statusType === 'success'
|
||||
? 'bg-green-500/10 border-green-500/40'
|
||||
: 'bg-red-500/10 border-red-500/40'
|
||||
? 'bg-green-50 border-green-300 dark:bg-green-500/10 dark:border-green-500/40'
|
||||
: 'bg-red-50 border-red-300 dark:bg-red-500/10 dark: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" />}
|
||||
{statusType === 'info' && <Loader2 className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin shrink-0" />}
|
||||
{statusType === 'success' && <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />}
|
||||
{statusType === 'error' && <XCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />}
|
||||
<span className={`text-sm ${
|
||||
statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
|
||||
statusType === 'info'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: statusType === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>{statusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AMS slots */}
|
||||
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto">
|
||||
{!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 className="flex-1 min-h-0 flex flex-col">
|
||||
{/* AMS slots */}
|
||||
<section className="flex min-h-[18rem] flex-col gap-3 bg-bambu-dark p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
{showPrinterPicker && (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white shrink-0">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-bambu-green text-[11px] font-bold text-[#ffffff]">2</span>
|
||||
{t('printModal.selectSlot')}
|
||||
</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={currentAssignment?.ams_id === unit.id ? (currentAssignment.tray_id ?? null) : null}
|
||||
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
|
||||
isDualNozzle={isDualNozzle}
|
||||
nozzleSide={getNozzleSide(unit.id)}
|
||||
thresholds={amsThresholds}
|
||||
fillOverrides={fillOverrides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Single-slot items (HT + External) */}
|
||||
{singleSlots.length > 0 && (
|
||||
<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 ${
|
||||
isActive ? 'ring-2 ring-bambu-green' : ''
|
||||
} ${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 && effectiveFill != null && effectiveFill >= 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: `${effectiveFill}%`,
|
||||
backgroundColor: getFillBarColor(effectiveFill),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!targetPrinterId ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
|
||||
<div className="text-center text-bambu-gray">
|
||||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
|
||||
<p className="text-base text-bambu-gray-light">{t('printModal.selectPrinter')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : statusLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-bambu-gray-light">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-bambu-green" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : !isConnected ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-xl border border-red-300 bg-red-50 p-8 dark:border-red-500/30 dark:bg-red-500/5">
|
||||
<p className="text-base text-red-700 dark:text-red-300">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
|
||||
</div>
|
||||
) : amsUnits.length === 0 && vtTrays.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-color)] bg-bambu-dark-secondary p-8">
|
||||
<div className="text-center text-bambu-gray">
|
||||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-60" />
|
||||
<p className="text-base text-bambu-gray-light mb-1">{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 xl:grid-cols-2 gap-3">
|
||||
{regularAms.map((unit) => (
|
||||
<AmsUnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
activeSlot={
|
||||
selectedSlot?.amsId === unit.id
|
||||
? selectedSlot.trayId
|
||||
: currentAssignment?.ams_id === unit.id
|
||||
? (currentAssignment.tray_id ?? null)
|
||||
: null
|
||||
}
|
||||
onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
|
||||
isDualNozzle={isDualNozzle}
|
||||
nozzleSide={getNozzleSide(unit.id)}
|
||||
thresholds={amsThresholds}
|
||||
fillOverrides={fillOverrides}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single-slot items (HT + External) */}
|
||||
{singleSlots.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
{singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
|
||||
const color = trayColorToCSS(tray.tray_color);
|
||||
const isActive = selectedSlot
|
||||
? selectedSlot.amsId === amsId && selectedSlot.trayId === trayId
|
||||
: !!currentAssignment && currentAssignment.ams_id === amsId && currentAssignment.tray_id === trayId;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
title={getSlotLocationLabel(amsId, trayId)}
|
||||
onClick={() => handleSlotClick(amsId, trayId)}
|
||||
className={`bg-bambu-dark-secondary border rounded-lg px-3 py-2 hover:bg-bambu-dark-tertiary transition-all flex items-center gap-2 ${
|
||||
isActive ? 'ring-2 ring-bambu-green border-bambu-green bg-bambu-green/10' : 'border-[var(--border-color)]'
|
||||
} ${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 text-left">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-bambu-gray font-medium">{label}</span>
|
||||
{nozzleSide && <NozzleBadge side={nozzleSide} />}
|
||||
</div>
|
||||
<div className="text-sm text-white truncate">
|
||||
{isEmpty ? t('ams.empty') : tray.tray_type || '?'}
|
||||
</div>
|
||||
</div>
|
||||
{!isEmpty && effectiveFill != null && effectiveFill >= 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: `${effectiveFill}%`,
|
||||
backgroundColor: getFillBarColor(effectiveFill),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
|
||||
<div className="flex items-center gap-3 bg-bambu-dark-secondary px-5 py-3 border-t border-[var(--border-color)] shrink-0">
|
||||
{requiresSlotConfirmation && (
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<span className="text-bambu-gray">{t('printModal.selectSlot')}: </span>
|
||||
<span className={selectedSlotLabel ? 'font-medium text-white' : 'text-bambu-gray'}>
|
||||
{selectedSlotLabel || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
className="px-5 py-2.5 rounded-lg border border-[var(--border-color)] text-sm font-medium bg-bambu-dark-tertiary text-white hover:bg-bambu-dark transition-colors min-h-[44px] disabled:opacity-50"
|
||||
>
|
||||
{statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
|
||||
{statusType === 'success' ? t('common.close') : t('common.cancel')}
|
||||
</button>
|
||||
{requiresSlotConfirmation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignSelectedSlot}
|
||||
disabled={!targetPrinterId || !selectedSlot || isWaiting || statusType === 'success'}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg bg-bambu-green px-5 py-2.5 text-sm font-semibold text-[#ffffff] transition-colors hover:bg-bambu-green-dark disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isWaiting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
{t('inventory.assignSpool')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMismatchConfirm && mismatchDetails && (() => {
|
||||
let message = '';
|
||||
@@ -571,6 +798,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
|
||||
message={message}
|
||||
confirmText={t('inventory.assignMismatchConfirm')}
|
||||
variant="warning"
|
||||
overlayZIndex="z-[70]"
|
||||
isLoading={configureMutation.isPending}
|
||||
onConfirm={handleConfirmMismatch}
|
||||
onCancel={() => {
|
||||
|
||||
Reference in New Issue
Block a user