|
@@ -1,9 +1,9 @@
|
|
|
-import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
|
|
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
|
-import { X, Check, Loader2 } from 'lucide-react';
|
|
|
|
|
-import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
|
|
|
|
|
-import { api, type AMSUnit, type AMSTray } from '../../api/client';
|
|
|
|
|
|
|
+import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
|
|
|
|
|
+import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
|
|
|
|
|
+import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
|
|
|
|
|
|
|
|
function getAmsName(id: number): string {
|
|
function getAmsName(id: number): string {
|
|
|
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
|
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
|
@@ -11,282 +11,296 @@ function getAmsName(id: number): string {
|
|
|
return `AMS ${id}`;
|
|
return `AMS ${id}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function isTrayEmpty(tray: AMSTray): boolean {
|
|
|
|
|
+ return !tray.tray_type || tray.tray_type === '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function trayColorToCSS(color: string | null): string {
|
|
function trayColorToCSS(color: string | null): string {
|
|
|
if (!color) return '#808080';
|
|
if (!color) return '#808080';
|
|
|
return `#${color.slice(0, 6)}`;
|
|
return `#${color.slice(0, 6)}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function isTrayEmpty(tray: AMSTray): boolean {
|
|
|
|
|
- return !tray.tray_type || tray.tray_type === '';
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
interface AssignToAmsModalProps {
|
|
interface AssignToAmsModalProps {
|
|
|
isOpen: boolean;
|
|
isOpen: boolean;
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
|
- spool: MatchedSpool;
|
|
|
|
|
|
|
+ spool: InventorySpool;
|
|
|
printerId: number | null;
|
|
printerId: number | null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
|
|
export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
- const [selectedPrinter, setSelectedPrinter] = useState<number | null>(printerId);
|
|
|
|
|
- const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
|
|
|
|
|
- const [showSuccess, setShowSuccess] = useState(false);
|
|
|
|
|
|
|
+ const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
|
|
|
+ const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
|
|
|
|
|
|
|
|
- // Reset state when modal opens
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (isOpen) {
|
|
if (isOpen) {
|
|
|
- setSelectedPrinter(printerId);
|
|
|
|
|
- setSelectedSlot(null);
|
|
|
|
|
- setShowSuccess(false);
|
|
|
|
|
|
|
+ setStatusMessage(null);
|
|
|
|
|
+ setStatusType(null);
|
|
|
}
|
|
}
|
|
|
- }, [isOpen, printerId]);
|
|
|
|
|
|
|
+ }, [isOpen]);
|
|
|
|
|
|
|
|
- // Escape key handler
|
|
|
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'Escape') onClose();
|
|
|
}, [onClose]);
|
|
}, [onClose]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (isOpen) {
|
|
|
|
|
- document.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
- }
|
|
|
|
|
- return () => {
|
|
|
|
|
- document.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
+ return () => document.removeEventListener('keydown', handleKeyDown);
|
|
|
}, [isOpen, handleKeyDown]);
|
|
}, [isOpen, handleKeyDown]);
|
|
|
|
|
|
|
|
- // Fetch printers
|
|
|
|
|
- const { data: printers = [] } = useQuery({
|
|
|
|
|
- queryKey: ['printers'],
|
|
|
|
|
- queryFn: () => api.getPrinters(),
|
|
|
|
|
- enabled: isOpen,
|
|
|
|
|
|
|
+ const { data: status } = useQuery<PrinterStatus>({
|
|
|
|
|
+ queryKey: ['printerStatus', printerId],
|
|
|
|
|
+ queryFn: () => api.getPrinterStatus(printerId!),
|
|
|
|
|
+ enabled: isOpen && printerId !== null,
|
|
|
|
|
+ refetchInterval: 5000,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Fetch printer status
|
|
|
|
|
- const { data: printerStatus } = useQuery({
|
|
|
|
|
- queryKey: ['printerStatus', selectedPrinter],
|
|
|
|
|
- queryFn: () => api.getPrinterStatus(selectedPrinter!),
|
|
|
|
|
- enabled: isOpen && selectedPrinter !== null,
|
|
|
|
|
- refetchInterval: 5000,
|
|
|
|
|
|
|
+ const { data: printer } = useQuery({
|
|
|
|
|
+ queryKey: ['printer', printerId],
|
|
|
|
|
+ queryFn: () => api.getPrinter(printerId!),
|
|
|
|
|
+ enabled: isOpen && printerId !== null,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Assignment mutation
|
|
|
|
|
- const assignMutation = useMutation({
|
|
|
|
|
- mutationFn: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
|
|
|
|
|
- api.assignSpool(data),
|
|
|
|
|
|
|
+ const isConnected = status?.connected ?? false;
|
|
|
|
|
+ const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
|
|
|
|
|
+ const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
|
|
|
|
|
+ const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
|
|
|
|
|
+ const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
|
|
|
|
|
+ const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
|
|
|
|
|
+
|
|
|
|
|
+ const cachedAmsExtruderMap = useRef<Record<string, number>>({});
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
|
|
|
|
|
+ cachedAmsExtruderMap.current = status.ams_extruder_map;
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [status?.ams_extruder_map]);
|
|
|
|
|
+ const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
|
|
|
|
|
+ ? status.ams_extruder_map
|
|
|
|
|
+ : cachedAmsExtruderMap.current;
|
|
|
|
|
+
|
|
|
|
|
+ const getNozzleSide = useCallback((amsId: number): 'L' | 'R' | null => {
|
|
|
|
|
+ if (!isDualNozzle) return null;
|
|
|
|
|
+ const mappedExtruderId = amsExtruderMap[String(amsId)];
|
|
|
|
|
+ const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
|
|
|
|
|
+ const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
|
|
|
|
|
+ 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).
|
|
|
|
|
+ const configureMutation = useMutation({
|
|
|
|
|
+ mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
|
|
|
|
|
+ if (!printerId) throw new Error('No printer selected');
|
|
|
|
|
+
|
|
|
|
|
+ await api.assignSpool({
|
|
|
|
|
+ spool_id: spool.id,
|
|
|
|
|
+ printer_id: printerId,
|
|
|
|
|
+ ams_id: amsId,
|
|
|
|
|
+ tray_id: trayId,
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
onSuccess: () => {
|
|
onSuccess: () => {
|
|
|
- setShowSuccess(true);
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- onClose();
|
|
|
|
|
- }, 1500);
|
|
|
|
|
|
|
+ setStatusType('success');
|
|
|
|
|
+ setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
|
|
|
|
|
+ setTimeout(() => onClose(), 1500);
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: (err) => {
|
|
|
|
|
+ setStatusType('error');
|
|
|
|
|
+ setStatusMessage(err instanceof Error ? err.message : t('spoolbuddy.modal.assignError', 'Failed to assign spool.'));
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- if (!isOpen) return null;
|
|
|
|
|
|
|
+ const isWaiting = configureMutation.isPending;
|
|
|
|
|
|
|
|
- const handleAssign = () => {
|
|
|
|
|
- if (!selectedPrinter || !selectedSlot) return;
|
|
|
|
|
- assignMutation.mutate({
|
|
|
|
|
- spool_id: spool.id,
|
|
|
|
|
- printer_id: selectedPrinter,
|
|
|
|
|
- ams_id: selectedSlot.amsId,
|
|
|
|
|
- tray_id: selectedSlot.trayId,
|
|
|
|
|
- });
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const handleSlotClick = useCallback((amsId: number, trayId: number) => {
|
|
|
|
|
+ if (isWaiting) return;
|
|
|
|
|
+ setStatusType('info');
|
|
|
|
|
+ setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
|
|
|
|
|
+ configureMutation.mutate({ amsId, trayId });
|
|
|
|
|
+ }, [isWaiting, configureMutation, t]);
|
|
|
|
|
|
|
|
- const amsUnits: AMSUnit[] = printerStatus?.ams ?? [];
|
|
|
|
|
- const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
|
|
|
|
|
|
+ // Build single-slot items (HT + External)
|
|
|
|
|
+ const singleSlots = useMemo(() => {
|
|
|
|
|
+ const items: {
|
|
|
|
|
+ key: string; label: string; amsId: number; trayId: number;
|
|
|
|
|
+ tray: AMSTray; isEmpty: boolean; nozzleSide: 'L' | 'R' | null;
|
|
|
|
|
+ }[] = [];
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
|
|
|
|
|
- <div
|
|
|
|
|
- className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
|
|
|
|
|
- onClick={(e) => e.stopPropagation()}
|
|
|
|
|
- >
|
|
|
|
|
- <div className="p-6">
|
|
|
|
|
- {/* Header */}
|
|
|
|
|
- <div className="flex items-center justify-between mb-5">
|
|
|
|
|
- <h2 className="text-lg font-semibold text-zinc-100">
|
|
|
|
|
- {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
|
|
|
|
|
- </h2>
|
|
|
|
|
- <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
|
|
|
|
|
- <X className="w-5 h-5" />
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ for (const unit of htAms) {
|
|
|
|
|
+ const tray = unit.tray?.[0] || {
|
|
|
|
|
+ id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
|
|
|
|
|
+ tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
|
|
|
|
|
+ cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
|
|
|
|
|
+ };
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ key: `ht-${unit.id}`, label: getAmsName(unit.id),
|
|
|
|
|
+ amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
|
|
|
|
|
+ nozzleSide: getNozzleSide(unit.id),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- {/* Spool summary */}
|
|
|
|
|
- <div className="flex items-center gap-3 p-3 bg-zinc-900/50 rounded-lg mb-5">
|
|
|
|
|
- <div className="w-8 h-8 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
|
|
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
|
|
- <span className="text-sm font-medium text-zinc-200 truncate block">
|
|
|
|
|
- {spool.color_name || 'Unknown'} • {spool.material}
|
|
|
|
|
- {spool.subtype && ` ${spool.subtype}`}
|
|
|
|
|
- </span>
|
|
|
|
|
- <span className="text-xs text-zinc-500">{spool.brand}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ for (const extTray of vtTrays) {
|
|
|
|
|
+ const extTrayId = extTray.id ?? 254;
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ key: `ext-${extTrayId}`,
|
|
|
|
|
+ label: isDualNozzle
|
|
|
|
|
+ ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
|
|
|
|
|
+ : t('printers.ext', 'Ext'),
|
|
|
|
|
+ amsId: 255, trayId: extTrayId - 254, tray: extTray,
|
|
|
|
|
+ isEmpty: isTrayEmpty(extTray),
|
|
|
|
|
+ nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- {/* Printer selector */}
|
|
|
|
|
- {printers.length > 1 && (
|
|
|
|
|
- <div className="mb-4">
|
|
|
|
|
- <select
|
|
|
|
|
- value={selectedPrinter ?? ''}
|
|
|
|
|
- onChange={(e) => {
|
|
|
|
|
- setSelectedPrinter(e.target.value ? Number(e.target.value) : null);
|
|
|
|
|
- setSelectedSlot(null);
|
|
|
|
|
- }}
|
|
|
|
|
- className="w-full px-3 py-2.5 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-zinc-200 min-h-[44px]"
|
|
|
|
|
- >
|
|
|
|
|
- <option value="">{t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}</option>
|
|
|
|
|
- {printers.map((p) => (
|
|
|
|
|
- <option key={p.id} value={p.id}>{p.name}</option>
|
|
|
|
|
- ))}
|
|
|
|
|
- </select>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ return items;
|
|
|
|
|
+ }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide]);
|
|
|
|
|
|
|
|
- {/* AMS slot grid */}
|
|
|
|
|
- {selectedPrinter === null ? (
|
|
|
|
|
- <div className="text-center py-8 text-zinc-500 text-sm">
|
|
|
|
|
- {t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : amsUnits.length === 0 ? (
|
|
|
|
|
- <div className="text-center py-8 text-zinc-500 text-sm">
|
|
|
|
|
- {t('spoolbuddy.modal.noAmsDetected', 'No AMS detected on this printer')}
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="space-y-3 max-h-[300px] overflow-y-auto">
|
|
|
|
|
- {amsUnits.map((unit) => (
|
|
|
|
|
- <AmsSlotSelector
|
|
|
|
|
- key={unit.id}
|
|
|
|
|
- unit={unit}
|
|
|
|
|
- selectedSlot={selectedSlot}
|
|
|
|
|
- onSelectSlot={(trayId) => setSelectedSlot({ amsId: unit.id, trayId })}
|
|
|
|
|
- />
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ if (!isOpen) return null;
|
|
|
|
|
|
|
|
- {/* Error message */}
|
|
|
|
|
- {assignMutation.isError && (
|
|
|
|
|
- <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400">
|
|
|
|
|
- {t('spoolbuddy.modal.assignError', 'Failed to assign spool. Please try again.')}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
|
|
|
|
|
|
|
- {/* Action buttons */}
|
|
|
|
|
- <div className="flex gap-3 mt-5">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={handleAssign}
|
|
|
|
|
- disabled={!selectedSlot || assignMutation.isPending || showSuccess}
|
|
|
|
|
- className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
|
|
|
|
|
- showSuccess
|
|
|
|
|
- ? 'bg-green-600/20 text-green-400'
|
|
|
|
|
- : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- {assignMutation.isPending ? (
|
|
|
|
|
- <>
|
|
|
|
|
- <Loader2 className="w-4 h-4 animate-spin inline-block mr-1.5" />
|
|
|
|
|
- {t('spoolbuddy.modal.assigning', 'Assigning...')}
|
|
|
|
|
- </>
|
|
|
|
|
- ) : showSuccess ? (
|
|
|
|
|
- <>
|
|
|
|
|
- <Check className="w-4 h-4 inline-block mr-1.5" />
|
|
|
|
|
- {t('spoolbuddy.modal.assignSuccess', 'Assigned!')}
|
|
|
|
|
- </>
|
|
|
|
|
- ) : (
|
|
|
|
|
- t('spoolbuddy.modal.assign', 'Assign')
|
|
|
|
|
- )}
|
|
|
|
|
- </button>
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={onClose}
|
|
|
|
|
- className="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.close', 'Close')}
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
|
|
|
|
|
+ {/* Header */}
|
|
|
|
|
+ <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
|
|
|
|
|
+ <div className="flex items-center gap-3 min-w-0">
|
|
|
|
|
+ <div className="w-7 h-7 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
|
|
|
|
|
+ <div className="min-w-0">
|
|
|
|
|
+ <h2 className="text-sm font-semibold text-zinc-100 truncate">
|
|
|
|
|
+ {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
|
|
|
|
|
+ <span className="font-normal text-zinc-500 ml-2">
|
|
|
|
|
+ {spool.color_name || 'Unknown'} • {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </h2>
|
|
|
</div>
|
|
</div>
|
|
|
</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"
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="w-5 h-5" />
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
|
|
|
|
|
-// --- AMS Unit slot selector ---
|
|
|
|
|
-
|
|
|
|
|
-interface AmsSlotSelectorProps {
|
|
|
|
|
- unit: AMSUnit;
|
|
|
|
|
- selectedSlot: { amsId: number; trayId: number } | null;
|
|
|
|
|
- onSelectSlot: (trayId: number) => void;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function AmsSlotSelector({ unit, selectedSlot, onSelectSlot }: AmsSlotSelectorProps) {
|
|
|
|
|
- const { t } = useTranslation();
|
|
|
|
|
- const trays = unit.tray || [];
|
|
|
|
|
- const isHt = unit.is_ams_ht;
|
|
|
|
|
- const slotCount = isHt ? 1 : 4;
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="bg-zinc-900/50 rounded-lg p-3">
|
|
|
|
|
- <div className="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">
|
|
|
|
|
- {getAmsName(unit.id)}
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px]' : 'grid-cols-4'} gap-2`}>
|
|
|
|
|
- {Array.from({ length: slotCount }).map((_, i) => {
|
|
|
|
|
- const tray: AMSTray = trays[i] || {
|
|
|
|
|
- id: i,
|
|
|
|
|
- tray_color: null,
|
|
|
|
|
- tray_type: '',
|
|
|
|
|
- tray_sub_brands: null,
|
|
|
|
|
- tray_id_name: null,
|
|
|
|
|
- tray_info_idx: null,
|
|
|
|
|
- remain: -1,
|
|
|
|
|
- k: null,
|
|
|
|
|
- cali_idx: null,
|
|
|
|
|
- tag_uid: null,
|
|
|
|
|
- tray_uuid: null,
|
|
|
|
|
- nozzle_temp_min: null,
|
|
|
|
|
- nozzle_temp_max: null,
|
|
|
|
|
- };
|
|
|
|
|
- const isEmpty = isTrayEmpty(tray);
|
|
|
|
|
- const color = trayColorToCSS(tray.tray_color);
|
|
|
|
|
- const isSelected = selectedSlot?.amsId === unit.id && selectedSlot.trayId === i;
|
|
|
|
|
|
|
+ {/* 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'
|
|
|
|
|
+ : statusType === 'success'
|
|
|
|
|
+ ? 'bg-green-500/10 border-green-500/40'
|
|
|
|
|
+ : 'bg-red-500/10 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" />}
|
|
|
|
|
+ <span className={`text-sm ${
|
|
|
|
|
+ statusType === 'info' ? 'text-blue-300' : statusType === 'success' ? 'text-green-300' : 'text-red-300'
|
|
|
|
|
+ }`}>{statusMessage}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <button
|
|
|
|
|
- key={i}
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => onSelectSlot(i)}
|
|
|
|
|
- className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all min-h-[44px] ${
|
|
|
|
|
- isSelected
|
|
|
|
|
- ? 'ring-2 ring-green-500 bg-green-500/10'
|
|
|
|
|
- : 'hover:bg-white/5'
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- {/* Color circle */}
|
|
|
|
|
- <div className="relative w-10 h-10 mb-1">
|
|
|
|
|
- {isEmpty ? (
|
|
|
|
|
- <div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
|
|
|
|
|
- <div className="w-2 h-2 rounded-full bg-zinc-600" />
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="w-full h-full rounded-full" style={{ backgroundColor: color }} />
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {/* AMS slots */}
|
|
|
|
|
+ <div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
|
|
|
|
|
+ {!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>
|
|
|
|
|
+ </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={null}
|
|
|
|
|
+ onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
|
|
|
|
|
+ isDualNozzle={isDualNozzle}
|
|
|
|
|
+ nozzleSide={getNozzleSide(unit.id)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ )}
|
|
|
|
|
|
|
|
- {/* Material */}
|
|
|
|
|
- <span className="text-xs text-zinc-400 truncate max-w-full">
|
|
|
|
|
- {isEmpty ? t('spoolbuddy.ams.empty', 'Empty') : tray.tray_type || '?'}
|
|
|
|
|
- </span>
|
|
|
|
|
|
|
+ {/* Single-slot items (HT + External) */}
|
|
|
|
|
+ {singleSlots.length > 0 && (
|
|
|
|
|
+ <div className="flex gap-2 shrink-0">
|
|
|
|
|
+ {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide }) => {
|
|
|
|
|
+ const color = trayColorToCSS(tray.tray_color);
|
|
|
|
|
+ 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' : ''
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <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 && tray.remain != null && tray.remain >= 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: `${tray.remain}%`,
|
|
|
|
|
+ backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- {/* Slot number */}
|
|
|
|
|
- <span className="absolute top-0.5 right-1 text-[10px] text-zinc-600">
|
|
|
|
|
- {t('spoolbuddy.modal.slot', 'Slot')} {i + 1}
|
|
|
|
|
- </span>
|
|
|
|
|
- </button>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
|
|
+ {/* Footer */}
|
|
|
|
|
+ <div className="flex justify-end gap-3 px-5 py-3 border-t border-zinc-800 shrink-0">
|
|
|
|
|
+ <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"
|
|
|
|
|
+ >
|
|
|
|
|
+ {statusType === 'success' ? t('spoolbuddy.dashboard.close', 'Close') : t('spoolbuddy.modal.cancel', 'Cancel')}
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|