|
|
@@ -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, printerId, spool.id]);
|
|
|
+
|
|
|
+ // 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) 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((e: KeyboardEvent) => {
|
|
|
- if (e.key === 'Escape') onClose();
|
|
|
- }, [onClose]);
|
|
|
+ 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) document.addEventListener('keydown', handleKeyDown);
|
|
|
+ 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>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!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>
|
|
|
- </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}
|
|
|
- />
|
|
|
- ))}
|
|
|
+ ) : 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>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* 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>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ {/* 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>
|
|
|
- {!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 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>
|
|
|
- )}
|
|
|
- </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={() => {
|