mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
@@ -256,6 +256,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
<button
|
||||
key={spool.id}
|
||||
onClick={() => setSelectedSpoolId(spool.id)}
|
||||
title={spool.note || undefined}
|
||||
className={`p-2.5 rounded-lg border text-left transition-colors ${
|
||||
selectedSpoolId === spool.id
|
||||
? 'bg-bambu-green/20 border-bambu-green'
|
||||
|
||||
@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
{provider.on_first_layer_complete && (
|
||||
<span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
|
||||
)}
|
||||
{provider.on_print_missing_spool_assignment && (
|
||||
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
|
||||
)}
|
||||
{provider.quiet_hours_enabled && (
|
||||
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
|
||||
<Moon className="w-3 h-3" />
|
||||
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_print_missing_spool_assignment ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">{t('notifications.printFailed')}</p>
|
||||
<Toggle
|
||||
|
||||
@@ -361,6 +361,25 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto Off Persistent */}
|
||||
{plug.auto_off && (
|
||||
<div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<p className="text-sm text-white">{t('smartPlugs.autoOffPersistent')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffPersistentDescription')}</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.auto_off_persistent}
|
||||
onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delay Mode */}
|
||||
{plug.auto_off && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag } from 'lucide-react';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
|
||||
spool?: InventorySpool | null;
|
||||
printersWithCalibrations?: PrinterWithCalibrations[];
|
||||
currencySymbol: string;
|
||||
onSpoolsCreated?: (spools: InventorySpool[]) => void;
|
||||
}
|
||||
|
||||
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
|
||||
export function SpoolFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
spool,
|
||||
printersWithCalibrations = [],
|
||||
currencySymbol,
|
||||
onSpoolsCreated,
|
||||
}: SpoolFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
await saveKProfiles(newSpool.id);
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
|
||||
showToast(t('inventory.spoolCreated'), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
}
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
if (onSpoolsCreated) onSpoolsCreated(newSpools);
|
||||
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
|
||||
onClose();
|
||||
},
|
||||
@@ -362,7 +372,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
|
||||
const deleteTagMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateSpool(spool!.id, { tag_uid: null } as Parameters<typeof api.updateSpool>[1]),
|
||||
api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
|
||||
@@ -373,6 +383,29 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch assignment for this spool (to show Unassign button)
|
||||
const { data: assignments } = useQuery({
|
||||
queryKey: ['spool-assignments'],
|
||||
queryFn: () => api.getAssignments(),
|
||||
enabled: isOpen && isEditing,
|
||||
});
|
||||
const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!spoolAssignment) throw new Error('No assignment');
|
||||
return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
||||
showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Save K-profiles for selected calibrations
|
||||
const saveKProfiles = async (spoolId: number) => {
|
||||
if (selectedProfiles.size === 0) {
|
||||
@@ -477,7 +510,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending;
|
||||
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">
|
||||
@@ -636,15 +669,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => deleteTagMutation.mutate()}
|
||||
disabled={isPending || !spool?.tag_uid}
|
||||
className="mr-auto"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('inventory.deleteTag', 'Delete Tag')}
|
||||
</Button>
|
||||
<div className="flex gap-2 mr-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => deleteTagMutation.mutate()}
|
||||
disabled={isPending || !spool?.tag_uid}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{t('inventory.deleteTag', 'Delete Tag')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => unassignMutation.mutate()}
|
||||
disabled={isPending || !spoolAssignment}
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
{t('inventory.unassignSpool', 'Unassign')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Keyboard from 'react-simple-keyboard';
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
import './VirtualKeyboard.css';
|
||||
|
||||
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
|
||||
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
|
||||
|
||||
/**
|
||||
* Set value on a controlled React input using the native setter,
|
||||
@@ -17,7 +17,7 @@ function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: st
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function VirtualKeyboard() {
|
||||
export function VirtualKeyboard({ onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const closingRef = useRef(false);
|
||||
@@ -26,6 +26,11 @@ export function VirtualKeyboard() {
|
||||
const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Notify parent when keyboard visibility changes
|
||||
useEffect(() => {
|
||||
onVisibilityChange?.(visible);
|
||||
}, [visible, onVisibilityChange]);
|
||||
|
||||
const handleFocusIn = useCallback((e: FocusEvent) => {
|
||||
if (closingRef.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -47,9 +52,10 @@ export function VirtualKeyboard() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(keyboardRef.current as any)?.setInput?.(activeInput.current.value);
|
||||
|
||||
// Scroll input into view above the keyboard
|
||||
// Scroll focused input into view after the keyboard renders and layout reflows
|
||||
setTimeout(() => {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const card = target.closest('.bg-zinc-800, .rounded-lg, [data-vkb-group]') as HTMLElement | null;
|
||||
(card ?? target).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
@@ -142,7 +148,7 @@ export function VirtualKeyboard() {
|
||||
{!closing && (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999]"
|
||||
className="relative z-[9999] shrink-0"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onTouchStart={(e) => {
|
||||
// Prevent focus loss but allow button interaction
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Loader2, 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';
|
||||
import type { AmsThresholds } from './AmsUnitCard';
|
||||
import { getFillBarColor } from '../../utils/amsHelpers';
|
||||
@@ -22,6 +23,34 @@ function trayColorToCSS(color: string | null): string {
|
||||
return `#${color.slice(0, 6)}`;
|
||||
}
|
||||
|
||||
// --- Material/profile mismatch helpers (pure functions, no component state) ---
|
||||
const normalizeValue = (value: string | undefined | null) =>
|
||||
(value ?? '').trim().toUpperCase();
|
||||
|
||||
function checkMaterialMatch(
|
||||
spoolMaterial: string | undefined | null,
|
||||
trayMaterial: string | undefined | null
|
||||
): 'exact' | 'partial' | 'none' {
|
||||
const normalizedSpool = normalizeValue(spoolMaterial);
|
||||
const normalizedTray = normalizeValue(trayMaterial);
|
||||
if (!normalizedSpool || !normalizedTray) return 'none';
|
||||
if (normalizedSpool === normalizedTray) return 'exact';
|
||||
if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
|
||||
return 'partial';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function checkProfileMatch(
|
||||
spoolProfile: string | undefined | null,
|
||||
trayProfile: string | undefined | null
|
||||
): boolean {
|
||||
const normalizedSpoolProfile = normalizeValue(spoolProfile);
|
||||
const normalizedTrayProfile = normalizeValue(trayProfile);
|
||||
if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
|
||||
return normalizedSpoolProfile === normalizedTrayProfile;
|
||||
}
|
||||
|
||||
interface AssignToAmsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -34,11 +63,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
const queryClient = useQueryClient();
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
|
||||
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
|
||||
const [mismatchDetails, setMismatchDetails] = useState<{
|
||||
type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';
|
||||
spoolMaterial: string;
|
||||
trayMaterial: string;
|
||||
spoolProfile?: string;
|
||||
trayProfile?: string;
|
||||
location: string;
|
||||
} | null>(null);
|
||||
const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
setShowMismatchConfirm(false);
|
||||
setMismatchDetails(null);
|
||||
setPendingSlot(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -155,12 +197,78 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
|
||||
const isWaiting = configureMutation.isPending;
|
||||
|
||||
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
|
||||
if (isWaiting) return;
|
||||
const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
|
||||
if (amsId === 254 || amsId === 255) {
|
||||
const extTrayId = amsId === 254 ? 254 : 254 + trayId;
|
||||
return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
|
||||
}
|
||||
const unit = amsUnits.find(u => u.id === amsId);
|
||||
return unit?.tray?.find(t => t.id === trayId) || null;
|
||||
}, [amsUnits, vtTrays]);
|
||||
|
||||
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');
|
||||
}, [t, isDualNozzle]);
|
||||
|
||||
const doAssign = useCallback((amsId: number, trayId: number) => {
|
||||
setStatusType('info');
|
||||
setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
|
||||
configureMutation.mutate({ amsId, trayId });
|
||||
}, [isWaiting, configureMutation, t]);
|
||||
}, [configureMutation, t]);
|
||||
|
||||
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
|
||||
if (isWaiting) return;
|
||||
|
||||
if (!settings?.disable_filament_warnings) {
|
||||
const tray = getTrayForSlot(amsId, trayId);
|
||||
if (tray && !isTrayEmpty(tray)) {
|
||||
const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';
|
||||
const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
|
||||
const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
|
||||
const trayProfile = tray.tray_type || '';
|
||||
const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
|
||||
|
||||
if (materialMatchResult !== 'exact' || !profileMatches) {
|
||||
let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';
|
||||
if (materialMatchResult === 'none' && !profileMatches) {
|
||||
mismatchType = 'material_profile';
|
||||
} else if (materialMatchResult === 'partial' && !profileMatches) {
|
||||
mismatchType = 'partial_profile';
|
||||
} else if (materialMatchResult === 'none') {
|
||||
mismatchType = 'material';
|
||||
} else if (materialMatchResult === 'partial') {
|
||||
mismatchType = 'partial';
|
||||
}
|
||||
|
||||
const location = getSlotLocationLabel(amsId, trayId);
|
||||
setPendingSlot({ amsId, trayId });
|
||||
setMismatchDetails({
|
||||
type: mismatchType,
|
||||
spoolMaterial: spool.material || '',
|
||||
trayMaterial: trayMaterial || '',
|
||||
spoolProfile: spoolProfile || undefined,
|
||||
trayProfile: trayProfile || undefined,
|
||||
location,
|
||||
});
|
||||
setShowMismatchConfirm(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doAssign(amsId, trayId);
|
||||
}, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
|
||||
|
||||
const handleConfirmMismatch = useCallback(() => {
|
||||
if (!pendingSlot) return;
|
||||
setShowMismatchConfirm(false);
|
||||
setMismatchDetails(null);
|
||||
doAssign(pendingSlot.amsId, pendingSlot.trayId);
|
||||
setPendingSlot(null);
|
||||
}, [pendingSlot, doAssign]);
|
||||
|
||||
// Build single-slot items (HT + External)
|
||||
const singleSlots = useMemo(() => {
|
||||
@@ -213,6 +321,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
|
||||
|
||||
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">
|
||||
@@ -255,7 +364,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
)}
|
||||
|
||||
{/* AMS slots */}
|
||||
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
|
||||
<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">
|
||||
@@ -358,5 +467,68 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMismatchConfirm && mismatchDetails && (() => {
|
||||
let message = '';
|
||||
|
||||
if (mismatchDetails.type === 'material') {
|
||||
message = t('inventory.assignMismatchMessage', {
|
||||
spoolMaterial: mismatchDetails.spoolMaterial,
|
||||
trayMaterial: mismatchDetails.trayMaterial,
|
||||
location: mismatchDetails.location,
|
||||
});
|
||||
} else if (mismatchDetails.type === 'partial') {
|
||||
message = t('inventory.assignPartialMismatchMessage', {
|
||||
spoolMaterial: mismatchDetails.spoolMaterial,
|
||||
trayMaterial: mismatchDetails.trayMaterial,
|
||||
location: mismatchDetails.location,
|
||||
});
|
||||
} else if (mismatchDetails.type === 'material_profile') {
|
||||
message = `${t('inventory.assignMismatchMessage', {
|
||||
spoolMaterial: mismatchDetails.spoolMaterial,
|
||||
trayMaterial: mismatchDetails.trayMaterial,
|
||||
location: mismatchDetails.location,
|
||||
})}\n\n${t('inventory.assignProfileMismatchMessage', {
|
||||
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
|
||||
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
|
||||
location: mismatchDetails.location,
|
||||
})}`;
|
||||
} else if (mismatchDetails.type === 'partial_profile') {
|
||||
message = `${t('inventory.assignPartialMismatchMessage', {
|
||||
spoolMaterial: mismatchDetails.spoolMaterial,
|
||||
trayMaterial: mismatchDetails.trayMaterial,
|
||||
location: mismatchDetails.location,
|
||||
})}\n\n${t('inventory.assignProfileMismatchMessage', {
|
||||
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
|
||||
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
|
||||
location: mismatchDetails.location,
|
||||
})}`;
|
||||
} else if (mismatchDetails.type === 'profile') {
|
||||
message = t('inventory.assignProfileMismatchMessage', {
|
||||
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
|
||||
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
|
||||
location: mismatchDetails.location,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t('inventory.assignMismatchTitle')}
|
||||
message={message}
|
||||
confirmText={t('inventory.assignMismatchConfirm')}
|
||||
variant="warning"
|
||||
isLoading={configureMutation.isPending}
|
||||
onConfirm={handleConfirmMismatch}
|
||||
onCancel={() => {
|
||||
if (!configureMutation.isPending) {
|
||||
setShowMismatchConfirm(false);
|
||||
setPendingSlot(null);
|
||||
setMismatchDetails(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Play, RotateCw } from 'lucide-react';
|
||||
import { spoolbuddyApi } from '../../api/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DiagnosticModalProps {
|
||||
type: 'scale' | 'nfc' | 'read_tag';
|
||||
deviceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isRunning) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isRunning, onClose]);
|
||||
|
||||
const runDiagnostic = useCallback(async () => {
|
||||
setIsRunning(true);
|
||||
setOutput('');
|
||||
setError('');
|
||||
setHasRun(true);
|
||||
|
||||
try {
|
||||
// Step 1: Queue the diagnostic on the device
|
||||
setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
|
||||
await spoolbuddyApi.queueDiagnostics(deviceId, type);
|
||||
|
||||
// Step 2: Poll for results with timeout
|
||||
let result = null;
|
||||
const maxRetries = 60; // 30s timeout with 500ms polling
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries && !result) {
|
||||
// Wait a bit before polling
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
try {
|
||||
result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
|
||||
break;
|
||||
} catch {
|
||||
// Not ready yet, continue polling
|
||||
retryCount++;
|
||||
if (retryCount % 4 === 0) {
|
||||
// Update every 2 seconds (after 4 retries of 500ms)
|
||||
setOutput(prev => prev + '.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Diagnostic timed out - device did not report results');
|
||||
}
|
||||
|
||||
setOutput(result.output);
|
||||
if (!result.success) {
|
||||
setError(`Exit code: ${result.exit_code}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
setOutput('');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [type, deviceId, t]);
|
||||
|
||||
const title = type === 'scale'
|
||||
? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
|
||||
: type === 'read_tag'
|
||||
? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
|
||||
: t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-4 border-b border-zinc-700">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
|
||||
<span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
|
||||
</div>
|
||||
) : output ? (
|
||||
<>
|
||||
<div className="text-green-400 whitespace-pre-wrap break-words">
|
||||
{output}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-400 mt-2">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : hasRun ? (
|
||||
<div>
|
||||
{error ? (
|
||||
<div className="text-red-400">ERROR: {error}</div>
|
||||
) : (
|
||||
<span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-500">
|
||||
{t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
|
||||
<button
|
||||
onClick={runDiagnostic}
|
||||
disabled={isRunning}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
{t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
|
||||
</>
|
||||
) : hasRun ? (
|
||||
<>
|
||||
<RotateCw size={16} />
|
||||
{t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
{t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
|
||||
>
|
||||
{t('spoolbuddy.diagnostic.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,16 @@ const navItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/spoolbuddy/inventory',
|
||||
labelKey: 'spoolbuddy.nav.inventory',
|
||||
fallback: 'Inventory',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/spoolbuddy/settings',
|
||||
labelKey: 'spoolbuddy.nav.settings',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
|
||||
import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
|
||||
import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
|
||||
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
|
||||
import { api, spoolbuddyApi } from '../../api/client';
|
||||
import { api, spoolbuddyApi, type Printer } from '../../api/client';
|
||||
import { VirtualKeyboard } from '../VirtualKeyboard';
|
||||
|
||||
export function SpoolBuddyLayout() {
|
||||
@@ -17,6 +17,8 @@ export function SpoolBuddyLayout() {
|
||||
const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
|
||||
const lastActivityRef = useRef(Date.now());
|
||||
const { i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const sbState = useSpoolBuddyState();
|
||||
|
||||
// Sync language from backend settings (kiosk has its own browser with empty localStorage)
|
||||
@@ -37,6 +39,11 @@ export function SpoolBuddyLayout() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
const device = devices[0];
|
||||
const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
|
||||
const sbStateForUi = useMemo(
|
||||
() => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
|
||||
[sbState, effectiveDeviceOnline]
|
||||
);
|
||||
|
||||
// Sync display settings from device on initial load
|
||||
const initializedRef = useRef(false);
|
||||
@@ -61,22 +68,22 @@ export function SpoolBuddyLayout() {
|
||||
// Auto-check for SpoolBuddy daemon updates
|
||||
const { data: updateCheck } = useQuery({
|
||||
queryKey: ['spoolbuddy-update-check', device?.device_id],
|
||||
queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id, true) : Promise.resolve(null),
|
||||
queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id) : Promise.resolve(null),
|
||||
enabled: !!device,
|
||||
refetchInterval: 5 * 60 * 1000, // re-check every 5 minutes
|
||||
staleTime: 4 * 60 * 1000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Update alert based on device state and available updates
|
||||
useEffect(() => {
|
||||
if (!sbState.deviceOnline) {
|
||||
if (!effectiveDeviceOnline) {
|
||||
setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
|
||||
} else if (updateCheck?.update_available && updateCheck.latest_version) {
|
||||
setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
|
||||
} else {
|
||||
setAlert(null);
|
||||
}
|
||||
}, [sbState.deviceOnline, updateCheck]);
|
||||
}, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
|
||||
|
||||
// Track user activity for screen blank
|
||||
const resetActivity = useCallback(() => {
|
||||
@@ -93,6 +100,19 @@ export function SpoolBuddyLayout() {
|
||||
};
|
||||
}, [resetActivity]);
|
||||
|
||||
// Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag)
|
||||
const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
|
||||
const prevTagDetected = useRef(false);
|
||||
useEffect(() => {
|
||||
if (tagDetected && !prevTagDetected.current) {
|
||||
resetActivity();
|
||||
if (location.pathname !== '/spoolbuddy') {
|
||||
navigate('/spoolbuddy');
|
||||
}
|
||||
}
|
||||
prevTagDetected.current = tagDetected;
|
||||
}, [tagDetected, location.pathname, navigate, resetActivity]);
|
||||
|
||||
// Screen blank timer
|
||||
useEffect(() => {
|
||||
if (displayBlankTimeout <= 0) return;
|
||||
@@ -104,6 +124,65 @@ export function SpoolBuddyLayout() {
|
||||
return () => clearInterval(interval);
|
||||
}, [displayBlankTimeout]);
|
||||
|
||||
// Online printers list for swipe-to-switch
|
||||
const { data: printers = [] } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: () => api.getPrinters(),
|
||||
});
|
||||
const statusQueries = useQueries({
|
||||
queries: printers.map((printer: Printer) => ({
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
queryFn: () => api.getPrinterStatus(printer.id),
|
||||
refetchInterval: 10000,
|
||||
})),
|
||||
});
|
||||
const onlinePrinters = useMemo(() => {
|
||||
return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
|
||||
}, [printers, statusQueries]);
|
||||
|
||||
// Swipe left/right to cycle through online printers
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const swipeLockedRef = useRef(false);
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
swipeLockedRef.current = false;
|
||||
}, []);
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (!touchStartRef.current || onlinePrinters.length < 2) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
|
||||
const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
|
||||
touchStartRef.current = null;
|
||||
swipeLockedRef.current = false;
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
|
||||
const currentIdx = onlinePrinters.findIndex((p: Printer) => p.id === selectedPrinterId);
|
||||
const nextIdx = dx < 0
|
||||
? (currentIdx + 1) % onlinePrinters.length // swipe left → next
|
||||
: (currentIdx - 1 + onlinePrinters.length) % onlinePrinters.length; // swipe right → prev
|
||||
setSelectedPrinterId(onlinePrinters[nextIdx].id);
|
||||
}, [onlinePrinters, selectedPrinterId, setSelectedPrinterId]);
|
||||
|
||||
// Block browser back/forward swipe gesture with non-passive touchmove listener
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!touchStartRef.current) return;
|
||||
const dx = Math.abs(e.touches[0].clientX - touchStartRef.current.x);
|
||||
const dy = Math.abs(e.touches[0].clientY - touchStartRef.current.y);
|
||||
// Once locked as horizontal, prevent default for the rest of this gesture
|
||||
if (swipeLockedRef.current) { e.preventDefault(); return; }
|
||||
if (dx > 10 && dx > dy) { swipeLockedRef.current = true; e.preventDefault(); }
|
||||
};
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
return () => el.removeEventListener('touchmove', onTouchMove);
|
||||
}, []);
|
||||
|
||||
// Track virtual keyboard visibility to hide bottom bars
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
|
||||
// CSS brightness filter (software dimming)
|
||||
const brightnessStyle = displayBrightness < 100
|
||||
? { filter: `brightness(${displayBrightness / 100})` } as const
|
||||
@@ -112,26 +191,30 @@ export function SpoolBuddyLayout() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-spoolbuddy-kiosk
|
||||
className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
|
||||
style={brightnessStyle}
|
||||
style={{ ...brightnessStyle, overscrollBehaviorX: 'none' }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<SpoolBuddyTopBar
|
||||
selectedPrinterId={selectedPrinterId}
|
||||
onPrinterChange={setSelectedPrinterId}
|
||||
deviceOnline={sbState.deviceOnline}
|
||||
deviceOnline={effectiveDeviceOnline}
|
||||
/>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet context={{
|
||||
selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
|
||||
selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
|
||||
displayBrightness, setDisplayBrightness,
|
||||
displayBlankTimeout, setDisplayBlankTimeout,
|
||||
}} />
|
||||
</main>
|
||||
|
||||
<SpoolBuddyStatusBar alert={alert} />
|
||||
<SpoolBuddyBottomNav />
|
||||
<VirtualKeyboard />
|
||||
{!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}
|
||||
{!keyboardVisible && <SpoolBuddyBottomNav />}
|
||||
<VirtualKeyboard onVisibilityChange={setKeyboardVisible} />
|
||||
</div>
|
||||
|
||||
{/* Screen blank overlay — touch to wake */}
|
||||
|
||||
Reference in New Issue
Block a user