Browse Source

- Moved print modal to global function
- Fixed bug in file manager where content of sub folders was not shown

maziggy 4 months ago
parent
commit
0ac344a764

+ 0 - 596
frontend/src/components/AddToQueueModal.tsx

@@ -1,596 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
-import { api } from '../api/client';
-import type { PrintQueueItemCreate } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { useToast } from '../contexts/ToastContext';
-import { getColorName } from '../utils/colors';
-
-interface AddToQueueModalProps {
-  archiveId: number;
-  archiveName: string;
-  onClose: () => void;
-}
-
-export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueModalProps) {
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-
-  const [printerId, setPrinterId] = useState<number | null>(null);
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
-  const [scheduledTime, setScheduledTime] = useState('');
-  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
-  const [autoOffAfter, setAutoOffAfter] = useState(false);
-  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
-
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-  });
-
-  // Fetch filament requirements from the archived 3MF
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', archiveId],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId),
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', printerId],
-    queryFn: () => api.getPrinterStatus(printerId!),
-    enabled: !!printerId,
-  });
-
-  // Set default printer if only one available
-  useEffect(() => {
-    if (printers?.length === 1 && !printerId) {
-      setPrinterId(printers[0].id);
-    }
-  }, [printers, printerId]);
-
-  // Clear manual mappings when printer changes
-  useEffect(() => {
-    setManualMappings({});
-  }, [printerId]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1;
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6);
-    };
-
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping array
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
-
-  const addMutation = useMutation({
-    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Added to print queue');
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to add to queue', 'error');
-    },
-  });
-
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
-    if (!printerId) {
-      showToast('Please select a printer', 'error');
-      return;
-    }
-
-    const data: PrintQueueItemCreate = {
-      printer_id: printerId,
-      archive_id: archiveId,
-      require_previous_success: requirePreviousSuccess,
-      auto_off_after: autoOffAfter,
-      manual_start: scheduleType === 'manual',
-      ams_mapping: amsMapping,
-    };
-
-    if (scheduleType === 'scheduled' && scheduledTime) {
-      data.scheduled_time = new Date(scheduledTime).toISOString();
-    }
-
-    addMutation.mutate(data);
-  };
-
-  // Get minimum datetime (now + 1 minute)
-  const getMinDateTime = () => {
-    const now = new Date();
-    now.setMinutes(now.getMinutes() + 1);
-    return now.toISOString().slice(0, 16);
-  };
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
-        <CardContent className="p-0">
-          {/* Header */}
-          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <div className="flex items-center gap-2">
-              <Calendar className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-xl font-semibold text-white">Schedule Print</h2>
-            </div>
-            <button
-              onClick={onClose}
-              className="text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-
-          {/* Form */}
-          <form onSubmit={handleSubmit} className="p-4 space-y-4">
-            {/* Archive name */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
-              <p className="text-white font-medium truncate">{archiveName}</p>
-            </div>
-
-            {/* Printer selection */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
-              {printers?.length === 0 ? (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                  <AlertCircle className="w-4 h-4" />
-                  No printers configured
-                </div>
-              ) : (
-                <select
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={printerId || ''}
-                  onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-                  required
-                >
-                  <option value="">Select printer...</option>
-                  {printers?.map((p) => (
-                    <option key={p.id} value={p.id}>{p.name}</option>
-                  ))}
-                </select>
-              )}
-            </div>
-
-            {/* Filament Mapping Section */}
-            {printerId && hasFilamentReqs && (
-              <div>
-                <button
-                  type="button"
-                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
-                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-                >
-                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
-                  <span>Filament Mapping</span>
-                  {filamentComparison.some(f => f.status === 'mismatch') ? (
-                    <span className="text-xs text-orange-400">(Type not found)</span>
-                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
-                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
-                  ) : (
-                    <span className="text-xs text-bambu-green">(Ready)</span>
-                  )}
-                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-                </button>
-
-                {showFilamentMapping && (
-                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                    <div className="flex items-center justify-between mb-2">
-                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-                      <button
-                        type="button"
-                        onClick={async () => {
-                          if (!printerId) return;
-                          setIsRefreshing(true);
-                          try {
-                            await api.refreshPrinterStatus(printerId);
-                            await new Promise((r) => setTimeout(r, 500));
-                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
-                          } finally {
-                            setIsRefreshing(false);
-                          }
-                        }}
-                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                        disabled={isRefreshing}
-                      >
-                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                        <span>Re-read</span>
-                      </button>
-                    </div>
-                    {filamentComparison.map((item, idx) => (
-                      <div
-                        key={idx}
-                        className="grid items-center gap-2 text-xs"
-                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                      >
-                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-                        </span>
-                        <span className="text-white truncate">
-                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                        </span>
-                        <span className="text-bambu-gray">→</span>
-                        <select
-                          value={item.loaded?.globalTrayId ?? ''}
-                          onChange={(e) => {
-                            const slotId = item.slot_id || 0;
-                            if (slotId > 0) {
-                              const value = e.target.value;
-                              if (value === '') {
-                                setManualMappings((prev) => {
-                                  const next = { ...prev };
-                                  delete next[slotId];
-                                  return next;
-                                });
-                              } else {
-                                setManualMappings((prev) => ({
-                                  ...prev,
-                                  [slotId]: parseInt(value, 10),
-                                }));
-                              }
-                            }
-                          }}
-                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                            item.status === 'match'
-                              ? 'border-bambu-green/50 text-bambu-green'
-                              : item.status === 'type_only'
-                              ? 'border-yellow-400/50 text-yellow-400'
-                              : 'border-orange-400/50 text-orange-400'
-                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                        >
-                          <option value="" className="bg-bambu-dark text-bambu-gray">
-                            -- Select slot --
-                          </option>
-                          {loadedFilaments.map((f) => (
-                            <option
-                              key={f.globalTrayId}
-                              value={f.globalTrayId}
-                              className="bg-bambu-dark text-white"
-                            >
-                              {f.label}: {f.type} ({f.colorName})
-                            </option>
-                          ))}
-                        </select>
-                        {item.status === 'match' ? (
-                          <Check className="w-3 h-3 text-bambu-green" />
-                        ) : item.status === 'type_only' ? (
-                          <span title="Same type, different color">
-                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                          </span>
-                        ) : (
-                          <span title="Filament type not loaded">
-                            <AlertTriangle className="w-3 h-3 text-orange-400" />
-                          </span>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            )}
-
-            {/* Schedule type */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
-              <div className="flex gap-2">
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'asap'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('asap')}
-                >
-                  <Clock className="w-4 h-4" />
-                  ASAP
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'scheduled'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('scheduled')}
-                >
-                  <Calendar className="w-4 h-4" />
-                  Scheduled
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'manual'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('manual')}
-                >
-                  <Hand className="w-4 h-4" />
-                  Queue Only
-                </button>
-              </div>
-            </div>
-
-            {/* Scheduled time input */}
-            {scheduleType === 'scheduled' && (
-              <div>
-                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
-                <input
-                  type="datetime-local"
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={scheduledTime}
-                  onChange={(e) => setScheduledTime(e.target.value)}
-                  min={getMinDateTime()}
-                  required
-                />
-              </div>
-            )}
-
-            {/* Require previous success */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="requirePrevious"
-                checked={requirePreviousSuccess}
-                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
-                Only start if previous print succeeded
-              </label>
-            </div>
-
-            {/* Auto power off */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="autoOffAfter"
-                checked={autoOffAfter}
-                onChange={(e) => setAutoOffAfter(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
-                <Power className="w-3.5 h-3.5" />
-                Power off printer when done
-              </label>
-            </div>
-
-            {/* Help text */}
-            <p className="text-xs text-bambu-gray">
-              {scheduleType === 'asap'
-                ? 'Print will start as soon as the printer is idle.'
-                : scheduleType === 'scheduled'
-                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
-                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
-            </p>
-
-            {/* Actions */}
-            <div className="flex gap-3 pt-2">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                Cancel
-              </Button>
-              <Button
-                type="submit"
-                className="flex-1"
-                disabled={addMutation.isPending || !printerId || printers?.length === 0}
-              >
-                {addMutation.isPending ? 'Adding...' : 'Add to Queue'}
-              </Button>
-            </div>
-          </form>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 0 - 756
frontend/src/components/EditQueueItemModal.tsx

@@ -1,756 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp, Layers, Settings } from 'lucide-react';
-import { api } from '../api/client';
-import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { useToast } from '../contexts/ToastContext';
-import { getColorName } from '../utils/colors';
-
-interface EditQueueItemModalProps {
-  item: PrintQueueItem;
-  onClose: () => void;
-}
-
-export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-
-  const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
-  const [selectedPlate, setSelectedPlate] = useState<number | null>(item.plate_id);
-
-  // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
-  const isPlaceholderDate = item.scheduled_time &&
-    new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
-
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
-    if (item.manual_start) return 'manual';
-    if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
-    return 'asap';
-  });
-  const [scheduledTime, setScheduledTime] = useState(() => {
-    if (item.scheduled_time && !isPlaceholderDate) {
-      // Convert ISO to local datetime-local format
-      const date = new Date(item.scheduled_time);
-      return date.toISOString().slice(0, 16);
-    }
-    return '';
-  });
-  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
-  const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
-  const [showFilamentMapping, setShowFilamentMapping] = useState(false);
-  const [showPrintOptions, setShowPrintOptions] = useState(false);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  // Print options
-  const [printOptions, setPrintOptions] = useState({
-    bed_levelling: item.bed_levelling ?? true,
-    flow_cali: item.flow_cali ?? false,
-    vibration_cali: item.vibration_cali ?? true,
-    layer_inspect: item.layer_inspect ?? false,
-    timelapse: item.timelapse ?? false,
-    use_ams: item.use_ams ?? true,
-  });
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  // Initialize from existing ams_mapping if present
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
-    if (item.ams_mapping && Array.isArray(item.ams_mapping)) {
-      const mappings: Record<number, number> = {};
-      item.ams_mapping.forEach((globalTrayId, idx) => {
-        if (globalTrayId !== -1) {
-          mappings[idx + 1] = globalTrayId;
-        }
-      });
-      return mappings;
-    }
-    return {};
-  });
-
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-  });
-
-  // Fetch available plates from the archived 3MF
-  const { data: platesData } = useQuery({
-    queryKey: ['archive-plates', item.archive_id],
-    queryFn: () => api.getArchivePlates(item.archive_id),
-  });
-
-  // Auto-select the first plate for single-plate files, or use existing plate_id
-  useEffect(() => {
-    if (platesData?.plates?.length === 1 && !selectedPlate) {
-      setSelectedPlate(platesData.plates[0].index);
-    }
-  }, [platesData, selectedPlate]);
-
-  const isMultiPlate = platesData?.is_multi_plate ?? false;
-  const plates = platesData?.plates ?? [];
-
-  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', item.archive_id, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(item.archive_id, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !isMultiPlate,
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', printerId],
-    queryFn: () => api.getPrinterStatus(printerId!),
-    enabled: printerId !== null,
-  });
-
-  // Clear manual mappings when printer or plate changes (but not on initial load)
-  const [initialPrinterId] = useState(item.printer_id);
-  const [initialPlateId] = useState(item.plate_id);
-  useEffect(() => {
-    if (printerId !== initialPrinterId || selectedPlate !== initialPlateId) {
-      setManualMappings({});
-    }
-  }, [printerId, initialPrinterId, selectedPlate, initialPlateId]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1;
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6);
-    };
-
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping array
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
-
-  const updateMutation = useMutation({
-    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Queue item updated');
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to update queue item', 'error');
-    },
-  });
-
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
-
-    const data: PrintQueueItemUpdate = {
-      printer_id: printerId,
-      require_previous_success: requirePreviousSuccess,
-      auto_off_after: autoOffAfter,
-      manual_start: scheduleType === 'manual',
-      ams_mapping: amsMapping,
-      plate_id: selectedPlate,
-      ...printOptions,
-    };
-
-    if (scheduleType === 'scheduled' && scheduledTime) {
-      data.scheduled_time = new Date(scheduledTime).toISOString();
-    } else {
-      data.scheduled_time = null;
-    }
-
-    updateMutation.mutate(data);
-  };
-
-  // Get minimum datetime (now + 1 minute)
-  const getMinDateTime = () => {
-    const now = new Date();
-    now.setMinutes(now.getMinutes() + 1);
-    return now.toISOString().slice(0, 16);
-  };
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
-        <CardContent className="p-0">
-          {/* Header */}
-          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <div className="flex items-center gap-2">
-              <Pencil className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
-            </div>
-            <button
-              onClick={onClose}
-              className="text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-
-          {/* Form */}
-          <form onSubmit={handleSubmit} className="p-4 space-y-4">
-            {/* Archive name */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
-              <p className="text-white font-medium truncate">
-                {item.archive_name || `Archive #${item.archive_id}`}
-              </p>
-            </div>
-
-            {/* Printer selection */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
-              {printers?.length === 0 ? (
-                <div className="flex items-center gap-2 text-red-400 text-sm">
-                  <AlertCircle className="w-4 h-4" />
-                  No printers configured
-                </div>
-              ) : (
-                <>
-                  <select
-                    className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
-                      printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
-                    }`}
-                    value={printerId ?? ''}
-                    onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-                  >
-                    <option value="">-- Select a printer --</option>
-                    {printers?.map((p) => (
-                      <option key={p.id} value={p.id}>{p.name}</option>
-                    ))}
-                  </select>
-                  {printerId === null && (
-                    <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
-                      <AlertCircle className="w-3 h-3" />
-                      Assign a printer to enable printing
-                    </p>
-                  )}
-                </>
-              )}
-            </div>
-
-            {/* Plate selection - show when multi-plate file detected */}
-            {isMultiPlate && plates.length > 1 && (
-              <div>
-                <div className="flex items-center gap-2 mb-2">
-                  <Layers className="w-4 h-4 text-bambu-gray" />
-                  <label className="text-sm text-bambu-gray">Select Plate to Print</label>
-                  {!selectedPlate && (
-                    <span className="text-xs text-orange-400 flex items-center gap-1">
-                      <AlertTriangle className="w-3 h-3" />
-                      Selection required
-                    </span>
-                  )}
-                </div>
-                <div className="grid grid-cols-2 gap-2">
-                  {plates.map((plate) => (
-                    <button
-                      key={plate.index}
-                      type="button"
-                      onClick={() => setSelectedPlate(plate.index)}
-                      className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-                        selectedPlate === plate.index
-                          ? 'border-bambu-green bg-bambu-green/10'
-                          : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                      }`}
-                    >
-                      {plate.has_thumbnail && plate.thumbnail_url ? (
-                        <img
-                          src={plate.thumbnail_url}
-                          alt={`Plate ${plate.index}`}
-                          className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-                        />
-                      ) : (
-                        <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                          <Layers className="w-5 h-5 text-bambu-gray" />
-                        </div>
-                      )}
-                      <div className="min-w-0 flex-1">
-                        <p className="text-sm text-white font-medium truncate">
-                          {plate.name || `Plate ${plate.index}`}
-                        </p>
-                        <p className="text-xs text-bambu-gray truncate">
-                          {plate.objects?.length > 0
-                            ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
-                            : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                        </p>
-                      </div>
-                      {selectedPlate === plate.index && (
-                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                      )}
-                    </button>
-                  ))}
-                </div>
-              </div>
-            )}
-
-            {/* Filament Mapping Section */}
-            {printerId !== null && (isMultiPlate ? selectedPlate !== null : true) && hasFilamentReqs && (
-              <div>
-                <button
-                  type="button"
-                  onClick={() => setShowFilamentMapping(!showFilamentMapping)}
-                  className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-                >
-                  <Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
-                  <span>Filament Mapping</span>
-                  {filamentComparison.some(f => f.status === 'mismatch') ? (
-                    <span className="text-xs text-orange-400">(Type not found)</span>
-                  ) : filamentComparison.some(f => f.status === 'type_only') ? (
-                    <span className="text-xs text-yellow-400">(Color mismatch)</span>
-                  ) : (
-                    <span className="text-xs text-bambu-green">(Ready)</span>
-                  )}
-                  {showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-                </button>
-
-                {showFilamentMapping && (
-                  <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                    <div className="flex items-center justify-between mb-2">
-                      <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
-                      <button
-                        type="button"
-                        onClick={async () => {
-                          if (!printerId) return;
-                          setIsRefreshing(true);
-                          try {
-                            await api.refreshPrinterStatus(printerId);
-                            await new Promise((r) => setTimeout(r, 500));
-                            await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
-                          } finally {
-                            setIsRefreshing(false);
-                          }
-                        }}
-                        className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                        disabled={isRefreshing}
-                      >
-                        <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                        <span>Re-read</span>
-                      </button>
-                    </div>
-                    {filamentComparison.map((item, idx) => (
-                      <div
-                        key={idx}
-                        className="grid items-center gap-2 text-xs"
-                        style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                      >
-                        <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                          <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-                        </span>
-                        <span className="text-white truncate">
-                          {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                        </span>
-                        <span className="text-bambu-gray">→</span>
-                        <select
-                          value={item.loaded?.globalTrayId ?? ''}
-                          onChange={(e) => {
-                            const slotId = item.slot_id || 0;
-                            if (slotId > 0) {
-                              const value = e.target.value;
-                              if (value === '') {
-                                setManualMappings((prev) => {
-                                  const next = { ...prev };
-                                  delete next[slotId];
-                                  return next;
-                                });
-                              } else {
-                                setManualMappings((prev) => ({
-                                  ...prev,
-                                  [slotId]: parseInt(value, 10),
-                                }));
-                              }
-                            }
-                          }}
-                          className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                            item.status === 'match'
-                              ? 'border-bambu-green/50 text-bambu-green'
-                              : item.status === 'type_only'
-                              ? 'border-yellow-400/50 text-yellow-400'
-                              : 'border-orange-400/50 text-orange-400'
-                          } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                          title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                        >
-                          <option value="" className="bg-bambu-dark text-bambu-gray">
-                            -- Select slot --
-                          </option>
-                          {loadedFilaments.map((f) => (
-                            <option
-                              key={f.globalTrayId}
-                              value={f.globalTrayId}
-                              className="bg-bambu-dark text-white"
-                            >
-                              {f.label}: {f.type} ({f.colorName})
-                            </option>
-                          ))}
-                        </select>
-                        {item.status === 'match' ? (
-                          <Check className="w-3 h-3 text-bambu-green" />
-                        ) : item.status === 'type_only' ? (
-                          <span title="Same type, different color">
-                            <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                          </span>
-                        ) : (
-                          <span title="Filament type not loaded">
-                            <AlertTriangle className="w-3 h-3 text-orange-400" />
-                          </span>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </div>
-            )}
-
-            {/* Print Options */}
-            <div>
-              <button
-                type="button"
-                onClick={() => setShowPrintOptions(!showPrintOptions)}
-                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-              >
-                <Settings className="w-4 h-4" />
-                <span>Print Options</span>
-                {showPrintOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-              </button>
-              {showPrintOptions && (
-                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                  {[
-                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
-                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
-                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
-                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
-                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
-                  ].map(({ key, label, desc }) => (
-                    <label key={key} className="flex items-center justify-between cursor-pointer group">
-                      <div>
-                        <span className="text-sm text-white">{label}</span>
-                        <p className="text-xs text-bambu-gray">{desc}</p>
-                      </div>
-                      <div
-                        className={`relative w-10 h-5 rounded-full transition-colors ${
-                          printOptions[key as keyof typeof printOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
-                        }`}
-                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof typeof printOptions] }))}
-                      >
-                        <div
-                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                            printOptions[key as keyof typeof printOptions] ? 'translate-x-5' : 'translate-x-0.5'
-                          }`}
-                        />
-                      </div>
-                    </label>
-                  ))}
-                </div>
-              )}
-            </div>
-
-            {/* Schedule type */}
-            <div>
-              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
-              <div className="flex gap-2">
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'asap'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('asap')}
-                >
-                  <Clock className="w-4 h-4" />
-                  ASAP
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'scheduled'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('scheduled')}
-                >
-                  <Calendar className="w-4 h-4" />
-                  Scheduled
-                </button>
-                <button
-                  type="button"
-                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
-                    scheduleType === 'manual'
-                      ? 'bg-bambu-green border-bambu-green text-white'
-                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
-                  }`}
-                  onClick={() => setScheduleType('manual')}
-                >
-                  <Hand className="w-4 h-4" />
-                  Queue Only
-                </button>
-              </div>
-            </div>
-
-            {/* Scheduled time input */}
-            {scheduleType === 'scheduled' && (
-              <div>
-                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
-                <input
-                  type="datetime-local"
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  value={scheduledTime}
-                  onChange={(e) => setScheduledTime(e.target.value)}
-                  min={getMinDateTime()}
-                  required
-                />
-              </div>
-            )}
-
-            {/* Require previous success */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="requirePrevious"
-                checked={requirePreviousSuccess}
-                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
-                Only start if previous print succeeded
-              </label>
-            </div>
-
-            {/* Auto power off */}
-            <div className="flex items-center gap-2">
-              <input
-                type="checkbox"
-                id="autoOffAfter"
-                checked={autoOffAfter}
-                onChange={(e) => setAutoOffAfter(e.target.checked)}
-                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-              />
-              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
-                <Power className="w-3.5 h-3.5" />
-                Power off printer when done
-              </label>
-            </div>
-
-            {/* Help text */}
-            <p className="text-xs text-bambu-gray">
-              {scheduleType === 'asap'
-                ? 'Print will start as soon as the printer is idle.'
-                : scheduleType === 'scheduled'
-                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
-                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
-            </p>
-
-            {/* Actions */}
-            <div className="flex gap-3 pt-2">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                Cancel
-              </Button>
-              <Button
-                type="submit"
-                className="flex-1"
-                disabled={updateMutation.isPending || printers?.length === 0}
-              >
-                {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
-              </Button>
-            </div>
-          </form>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 186 - 0
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -0,0 +1,186 @@
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
+import { api } from '../../api/client';
+import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { getColorName } from '../../utils/colors';
+import type { FilamentMappingProps } from './types';
+
+/**
+ * Filament mapping UI for comparing required filaments with loaded AMS slots.
+ * Shows auto-matched and manually overridden slot assignments.
+ */
+export function FilamentMapping({
+  printerId,
+  archiveId,
+  selectedPlate,
+  isMultiPlate,
+  manualMappings,
+  onManualMappingChange,
+}: FilamentMappingProps) {
+  const queryClient = useQueryClient();
+  const [isRefreshing, setIsRefreshing] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', archiveId, selectedPlate],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
+    enabled: selectedPlate !== null || !isMultiPlate,
+  });
+
+  // Fetch printer status
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', printerId],
+    queryFn: () => api.getPrinterStatus(printerId),
+    enabled: !!printerId,
+  });
+
+  const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
+    useFilamentMapping(filamentReqs, printerStatus, manualMappings);
+
+  const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+
+  // Don't render if no filament requirements
+  if (!hasFilamentReqs) {
+    return null;
+  }
+
+  // Don't render until we have printer status to do the comparison
+  if (!printerStatus) {
+    return null;
+  }
+
+  // Determine status indicator color
+  const statusColor = hasTypeMismatch
+    ? '#f97316' // orange
+    : hasColorMismatch
+    ? '#facc15' // yellow
+    : '#00ae42'; // green
+
+  const handleSlotChange = (slotId: number, value: string) => {
+    if (slotId > 0) {
+      if (value === '') {
+        // Clear manual override
+        const next = { ...manualMappings };
+        delete next[slotId];
+        onManualMappingChange(next);
+      } else {
+        onManualMappingChange({
+          ...manualMappings,
+          [slotId]: parseInt(value, 10),
+        });
+      }
+    }
+  };
+
+  const handleRefresh = async () => {
+    setIsRefreshing(true);
+    try {
+      // Request fresh data from printer via MQTT pushall command
+      await api.refreshPrinterStatus(printerId);
+      // Wait a moment for printer to respond, then refetch
+      await new Promise((r) => setTimeout(r, 500));
+      await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  return (
+    <div className="mb-4">
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+      >
+        <Circle className="w-4 h-4" fill={statusColor} stroke="none" />
+        <span>Filament Mapping</span>
+        {hasTypeMismatch ? (
+          <span className="text-xs text-orange-400">(Type not found)</span>
+        ) : hasColorMismatch ? (
+          <span className="text-xs text-yellow-400">(Color mismatch)</span>
+        ) : (
+          <span className="text-xs text-bambu-green">(Ready)</span>
+        )}
+        {isExpanded ? (
+          <ChevronUp className="w-4 h-4 ml-auto" />
+        ) : (
+          <ChevronDown className="w-4 h-4 ml-auto" />
+        )}
+      </button>
+
+      {isExpanded && (
+        <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+          <div className="flex items-center justify-between mb-2">
+            <span className="text-xs text-bambu-gray">Click to change slot assignment</span>
+            <button
+              type="button"
+              onClick={handleRefresh}
+              className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+              disabled={isRefreshing}
+            >
+              <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+              <span>Re-read</span>
+            </button>
+          </div>
+          {filamentComparison.map((item, idx) => (
+            <div
+              key={idx}
+              className="grid items-center gap-2 text-xs"
+              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+            >
+              {/* Required color */}
+              <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
+              </span>
+              {/* Required type + grams */}
+              <span className="text-white truncate">
+                {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
+              </span>
+              {/* Arrow */}
+              <span className="text-bambu-gray">→</span>
+              {/* Slot selector dropdown */}
+              <select
+                value={item.loaded?.globalTrayId ?? ''}
+                onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
+                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                  item.status === 'match'
+                    ? 'border-bambu-green/50 text-bambu-green'
+                    : item.status === 'type_only'
+                    ? 'border-yellow-400/50 text-yellow-400'
+                    : 'border-orange-400/50 text-orange-400'
+                } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+              >
+                <option value="" className="bg-bambu-dark text-bambu-gray">
+                  -- Select slot --
+                </option>
+                {loadedFilaments.map((f) => (
+                  <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                    {f.label}: {f.type} ({f.colorName})
+                  </option>
+                ))}
+              </select>
+              {/* Status icon */}
+              {item.status === 'match' ? (
+                <Check className="w-3 h-3 text-bambu-green" />
+              ) : item.status === 'type_only' ? (
+                <span title="Same type, different color">
+                  <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                </span>
+              ) : (
+                <span title="Filament type not loaded">
+                  <AlertTriangle className="w-3 h-3 text-orange-400" />
+                </span>
+              )}
+            </div>
+          ))}
+          {hasTypeMismatch && (
+            <p className="text-xs text-orange-400 mt-2">Required filament type not found in printer.</p>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 75 - 0
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -0,0 +1,75 @@
+import { Layers, Check, AlertTriangle } from 'lucide-react';
+import { formatTime } from '../../utils/amsHelpers';
+import type { PlateSelectorProps } from './types';
+
+/**
+ * Plate selection grid for multi-plate 3MF files.
+ * Shows thumbnails, names, objects, and print times for each plate.
+ */
+export function PlateSelector({
+  plates,
+  isMultiPlate,
+  selectedPlate,
+  onSelect,
+}: PlateSelectorProps) {
+  // Only show for multi-plate files with multiple plates
+  if (!isMultiPlate || plates.length <= 1) {
+    return null;
+  }
+
+  return (
+    <div className="mb-4">
+      <div className="flex items-center gap-2 mb-2">
+        <Layers className="w-4 h-4 text-bambu-gray" />
+        <span className="text-sm text-bambu-gray">Select Plate to Print</span>
+        {!selectedPlate && (
+          <span className="text-xs text-orange-400 flex items-center gap-1">
+            <AlertTriangle className="w-3 h-3" />
+            Selection required
+          </span>
+        )}
+      </div>
+      <div className="grid grid-cols-2 gap-2">
+        {plates.map((plate) => (
+          <button
+            key={plate.index}
+            type="button"
+            onClick={() => onSelect(plate.index)}
+            className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+              selectedPlate === plate.index
+                ? 'border-bambu-green bg-bambu-green/10'
+                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+            }`}
+          >
+            {plate.has_thumbnail && plate.thumbnail_url != null ? (
+              <img
+                src={plate.thumbnail_url}
+                alt={`Plate ${plate.index}`}
+                className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+              />
+            ) : (
+              <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                <Layers className="w-5 h-5 text-bambu-gray" />
+              </div>
+            )}
+            <div className="min-w-0 flex-1">
+              <p className="text-sm text-white font-medium truncate">
+                {plate.name || `Plate ${plate.index}`}
+              </p>
+              <p className="text-xs text-bambu-gray truncate">
+                {plate.objects.length > 0
+                  ? plate.objects.slice(0, 3).join(', ') +
+                    (plate.objects.length > 3 ? '...' : '')
+                  : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                {plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
+              </p>
+            </div>
+            {selectedPlate === plate.index && (
+              <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+            )}
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 69 - 0
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -0,0 +1,69 @@
+import { useState } from 'react';
+import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
+import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';
+
+const PRINT_OPTIONS_CONFIG = [
+  { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
+  { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
+  { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
+  { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
+  { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
+] as const;
+
+/**
+ * Print options toggle panel with collapsible UI.
+ * Shows bed levelling, flow/vibration calibration, layer inspection, and timelapse options.
+ */
+export function PrintOptionsPanel({
+  options,
+  onChange,
+  defaultExpanded = false,
+}: PrintOptionsProps) {
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+  const handleToggle = (key: keyof PrintOptionsType) => {
+    onChange({ ...options, [key]: !options[key] });
+  };
+
+  return (
+    <div className="mb-4">
+      <button
+        type="button"
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+      >
+        <Settings className="w-4 h-4" />
+        <span>Print Options</span>
+        {isExpanded ? (
+          <ChevronUp className="w-4 h-4 ml-auto" />
+        ) : (
+          <ChevronDown className="w-4 h-4 ml-auto" />
+        )}
+      </button>
+      {isExpanded && (
+        <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+          {PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => (
+            <label key={key} className="flex items-center justify-between cursor-pointer group">
+              <div>
+                <span className="text-sm text-white">{label}</span>
+                <p className="text-xs text-bambu-gray">{desc}</p>
+              </div>
+              <div
+                className={`relative w-10 h-5 rounded-full transition-colors ${
+                  options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                }`}
+                onClick={() => handleToggle(key)}
+              >
+                <div
+                  className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
+                    options[key] ? 'translate-x-5' : 'translate-x-0.5'
+                  }`}
+                />
+              </div>
+            </label>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 105 - 0
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -0,0 +1,105 @@
+import { Printer as PrinterIcon, Loader2, AlertCircle } from 'lucide-react';
+import type { PrinterSelectorProps } from './types';
+
+/**
+ * Printer selection component with two modes:
+ * - Grid mode (default): Shows printers as selectable cards
+ * - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true)
+ */
+export function PrinterSelector({
+  printers,
+  selectedPrinterId,
+  onSelect,
+  isLoading = false,
+  allowUnassigned = false,
+}: PrinterSelectorProps) {
+  const activePrinters = printers.filter((p) => p.is_active);
+
+  if (isLoading) {
+    return (
+      <div className="flex justify-center py-8">
+        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  // Use dropdown mode for edit scenarios (allows unassigning printer)
+  if (allowUnassigned) {
+    return (
+      <div>
+        <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+        {printers.length === 0 ? (
+          <div className="flex items-center gap-2 text-red-400 text-sm">
+            <AlertCircle className="w-4 h-4" />
+            No printers configured
+          </div>
+        ) : (
+          <>
+            <select
+              className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
+                selectedPrinterId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
+              }`}
+              value={selectedPrinterId ?? ''}
+              onChange={(e) => onSelect(e.target.value ? Number(e.target.value) : null)}
+            >
+              <option value="">-- Select a printer --</option>
+              {printers.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+            {selectedPrinterId === null && (
+              <p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
+                <AlertCircle className="w-3 h-3" />
+                Assign a printer to enable printing
+              </p>
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+
+  // Grid mode for reprint/add-to-queue (only active printers)
+  if (activePrinters.length === 0) {
+    return (
+      <div className="text-center py-8 text-bambu-gray">No active printers available</div>
+    );
+  }
+
+  return (
+    <div className="space-y-2 mb-6">
+      {activePrinters.map((printer) => (
+        <button
+          key={printer.id}
+          type="button"
+          onClick={() => onSelect(printer.id)}
+          className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
+            selectedPrinterId === printer.id
+              ? 'border-bambu-green bg-bambu-green/10'
+              : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+          }`}
+        >
+          <div
+            className={`p-2 rounded-lg ${
+              selectedPrinterId === printer.id ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
+            }`}
+          >
+            <PrinterIcon
+              className={`w-5 h-5 ${
+                selectedPrinterId === printer.id ? 'text-bambu-green' : 'text-bambu-gray'
+              }`}
+            />
+          </div>
+          <div className="text-left">
+            <p className="text-white font-medium">{printer.name}</p>
+            <p className="text-xs text-bambu-gray">
+              {printer.model || 'Unknown model'} • {printer.ip_address}
+            </p>
+          </div>
+        </button>
+      ))}
+    </div>
+  );
+}

+ 114 - 0
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -0,0 +1,114 @@
+import { Calendar, Clock, Hand, Power } from 'lucide-react';
+import { getMinDateTime } from '../../utils/amsHelpers';
+import type { ScheduleOptionsProps, ScheduleType } from './types';
+
+/**
+ * Schedule options component for queue items.
+ * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
+ * and options for require previous success and auto power off.
+ */
+export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) {
+  const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
+    onChange({ ...options, scheduleType });
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Schedule type */}
+      <div>
+        <label className="block text-sm text-bambu-gray mb-2">When to print</label>
+        <div className="flex gap-2">
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'asap'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('asap')}
+          >
+            <Clock className="w-4 h-4" />
+            ASAP
+          </button>
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'scheduled'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('scheduled')}
+          >
+            <Calendar className="w-4 h-4" />
+            Scheduled
+          </button>
+          <button
+            type="button"
+            className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+              options.scheduleType === 'manual'
+                ? 'bg-bambu-green border-bambu-green text-white'
+                : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            onClick={() => handleScheduleTypeChange('manual')}
+          >
+            <Hand className="w-4 h-4" />
+            Queue Only
+          </button>
+        </div>
+      </div>
+
+      {/* Scheduled time input */}
+      {options.scheduleType === 'scheduled' && (
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+          <input
+            type="datetime-local"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            value={options.scheduledTime}
+            onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
+            min={getMinDateTime()}
+            required
+          />
+        </div>
+      )}
+
+      {/* Require previous success */}
+      <div className="flex items-center gap-2">
+        <input
+          type="checkbox"
+          id="requirePrevious"
+          checked={options.requirePreviousSuccess}
+          onChange={(e) => onChange({ ...options, requirePreviousSuccess: e.target.checked })}
+          className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+        />
+        <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
+          Only start if previous print succeeded
+        </label>
+      </div>
+
+      {/* Auto power off */}
+      <div className="flex items-center gap-2">
+        <input
+          type="checkbox"
+          id="autoOffAfter"
+          checked={options.autoOffAfter}
+          onChange={(e) => onChange({ ...options, autoOffAfter: e.target.checked })}
+          className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+        />
+        <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
+          <Power className="w-3.5 h-3.5" />
+          Power off printer when done
+        </label>
+      </div>
+
+      {/* Help text */}
+      <p className="text-xs text-bambu-gray">
+        {options.scheduleType === 'asap'
+          ? 'Print will start as soon as the printer is idle.'
+          : options.scheduleType === 'scheduled'
+          ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
+          : "Print will be staged but won't start automatically. Use the Start button to release it to the queue."}
+      </p>
+    </div>
+  );
+}

+ 440 - 0
frontend/src/components/PrintModal/index.tsx

@@ -0,0 +1,440 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Printer, Loader2, Calendar, Pencil } from 'lucide-react';
+import { api } from '../../api/client';
+import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
+import { Card, CardContent } from '../Card';
+import { Button } from '../Button';
+import { useToast } from '../../contexts/ToastContext';
+import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { isPlaceholderDate } from '../../utils/amsHelpers';
+import { PrinterSelector } from './PrinterSelector';
+import { PlateSelector } from './PlateSelector';
+import { FilamentMapping } from './FilamentMapping';
+import { PrintOptionsPanel } from './PrintOptions';
+import { ScheduleOptionsPanel } from './ScheduleOptions';
+import type {
+  PrintModalProps,
+  PrintOptions,
+  ScheduleOptions,
+  ScheduleType,
+} from './types';
+import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
+
+/**
+ * Unified PrintModal component that handles three modes:
+ * - 'reprint': Immediate print from archive
+ * - 'add-to-queue': Schedule print to queue
+ * - 'edit-queue-item': Edit existing queue item
+ */
+export function PrintModal({
+  mode,
+  archiveId,
+  archiveName,
+  queueItem,
+  onClose,
+  onSuccess,
+}: PrintModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  // Initialize state based on mode
+  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      return queueItem.printer_id;
+    }
+    return null;
+  });
+
+  const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      return queueItem.plate_id;
+    }
+    return null;
+  });
+
+  const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      return {
+        bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
+        flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
+        vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
+        layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
+        timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
+      };
+    }
+    return DEFAULT_PRINT_OPTIONS;
+  });
+
+  const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
+    if (mode === 'edit-queue-item' && queueItem) {
+      // Determine schedule type from queue item
+      let scheduleType: ScheduleType = 'asap';
+      if (queueItem.manual_start) {
+        scheduleType = 'manual';
+      } else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
+        scheduleType = 'scheduled';
+      }
+
+      // Convert scheduled time to local datetime-local format
+      let scheduledTime = '';
+      if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
+        const date = new Date(queueItem.scheduled_time);
+        scheduledTime = date.toISOString().slice(0, 16);
+      }
+
+      return {
+        scheduleType,
+        scheduledTime,
+        requirePreviousSuccess: queueItem.require_previous_success,
+        autoOffAfter: queueItem.auto_off_after,
+      };
+    }
+    return DEFAULT_SCHEDULE_OPTIONS;
+  });
+
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
+      const mappings: Record<number, number> = {};
+      queueItem.ams_mapping.forEach((globalTrayId, idx) => {
+        if (globalTrayId !== -1) {
+          mappings[idx + 1] = globalTrayId;
+        }
+      });
+      return mappings;
+    }
+    return {};
+  });
+
+  // Track initial values for clearing mappings on change (edit mode only)
+  const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null));
+  const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
+
+  // Queries
+  const { data: printers, isLoading: loadingPrinters } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const { data: platesData } = useQuery({
+    queryKey: ['archive-plates', archiveId],
+    queryFn: () => api.getArchivePlates(archiveId),
+  });
+
+  const { data: filamentReqs } = useQuery({
+    queryKey: ['archive-filaments', archiveId, selectedPlate],
+    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
+    enabled: selectedPlate !== null || !platesData?.is_multi_plate,
+  });
+
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printer-status', selectedPrinter],
+    queryFn: () => api.getPrinterStatus(selectedPrinter!),
+    enabled: !!selectedPrinter,
+  });
+
+  // Get AMS mapping from hook
+  const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings);
+
+  // Auto-select first plate for single-plate files
+  useEffect(() => {
+    if (platesData?.plates?.length === 1 && !selectedPlate) {
+      setSelectedPlate(platesData.plates[0].index);
+    }
+  }, [platesData, selectedPlate]);
+
+  // Auto-select first printer when only one available (add-to-queue mode)
+  useEffect(() => {
+    if (mode === 'add-to-queue' && printers?.length === 1 && !selectedPrinter) {
+      setSelectedPrinter(printers[0].id);
+    }
+  }, [mode, printers, selectedPrinter]);
+
+  // Clear manual mappings when printer or plate changes
+  useEffect(() => {
+    if (mode === 'edit-queue-item') {
+      // Only clear if changed from initial values
+      if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) {
+        setManualMappings({});
+      }
+    } else {
+      // Always clear on change for non-edit modes
+      setManualMappings({});
+    }
+  }, [mode, selectedPrinter, selectedPlate, initialPrinterId, initialPlateId]);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const isMultiPlate = platesData?.is_multi_plate ?? false;
+  const plates = platesData?.plates ?? [];
+
+  // Reprint mutation
+  const reprintMutation = useMutation({
+    mutationFn: () => {
+      if (!selectedPrinter) throw new Error('No printer selected');
+      return api.reprintArchive(archiveId, selectedPrinter, {
+        plate_id: selectedPlate ?? undefined,
+        ams_mapping: amsMapping,
+        ...printOptions,
+      });
+    },
+    onSuccess: () => {
+      showToast('Print started');
+      onSuccess?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to start print', 'error');
+    },
+  });
+
+  // Add to queue mutation
+  const addToQueueMutation = useMutation({
+    mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Added to print queue');
+      onSuccess?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to add to queue', 'error');
+    },
+  });
+
+  // Update queue item mutation
+  const updateQueueMutation = useMutation({
+    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Queue item updated');
+      onSuccess?.();
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update queue item', 'error');
+    },
+  });
+
+  const handleSubmit = (e?: React.FormEvent) => {
+    e?.preventDefault();
+
+    if (mode === 'reprint') {
+      if (!selectedPrinter) {
+        showToast('Please select a printer', 'error');
+        return;
+      }
+      reprintMutation.mutate();
+    } else if (mode === 'add-to-queue') {
+      if (!selectedPrinter) {
+        showToast('Please select a printer', 'error');
+        return;
+      }
+
+      const data: PrintQueueItemCreate = {
+        printer_id: selectedPrinter,
+        archive_id: archiveId,
+        require_previous_success: scheduleOptions.requirePreviousSuccess,
+        auto_off_after: scheduleOptions.autoOffAfter,
+        manual_start: scheduleOptions.scheduleType === 'manual',
+        ams_mapping: amsMapping,
+        plate_id: selectedPlate,
+        ...printOptions,
+      };
+
+      if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
+        data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
+      }
+
+      addToQueueMutation.mutate(data);
+    } else if (mode === 'edit-queue-item') {
+      const data: PrintQueueItemUpdate = {
+        printer_id: selectedPrinter,
+        require_previous_success: scheduleOptions.requirePreviousSuccess,
+        auto_off_after: scheduleOptions.autoOffAfter,
+        manual_start: scheduleOptions.scheduleType === 'manual',
+        ams_mapping: amsMapping,
+        plate_id: selectedPlate,
+        ...printOptions,
+      };
+
+      if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
+        data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
+      } else {
+        data.scheduled_time = null;
+      }
+
+      updateQueueMutation.mutate(data);
+    }
+  };
+
+  const isPending =
+    reprintMutation.isPending || addToQueueMutation.isPending || updateQueueMutation.isPending;
+
+  const canSubmit = useMemo(() => {
+    // For edit mode, printer can be null (unassigned)
+    if (mode === 'edit-queue-item') {
+      return !isPending && (printers?.length ?? 0) > 0;
+    }
+    // For reprint and add-to-queue, need a selected printer
+    if (!selectedPrinter) return false;
+    // For multi-plate files, need a selected plate
+    if (isMultiPlate && !selectedPlate) return false;
+    return !isPending;
+  }, [mode, selectedPrinter, isMultiPlate, selectedPlate, isPending, printers]);
+
+  // Modal title and action button text based on mode
+  const modalConfig = {
+    reprint: {
+      title: 'Re-print',
+      icon: Printer,
+      submitText: 'Print',
+      submitIcon: Printer,
+      loadingText: 'Sending...',
+    },
+    'add-to-queue': {
+      title: 'Schedule Print',
+      icon: Calendar,
+      submitText: 'Add to Queue',
+      submitIcon: Calendar,
+      loadingText: 'Adding...',
+    },
+    'edit-queue-item': {
+      title: 'Edit Queue Item',
+      icon: Pencil,
+      submitText: 'Save Changes',
+      submitIcon: Pencil,
+      loadingText: 'Saving...',
+    },
+  }[mode];
+
+  const TitleIcon = modalConfig.icon;
+  const SubmitIcon = modalConfig.submitIcon;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card
+        className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <CardContent className={mode === 'reprint' ? '' : 'p-0'}>
+          {/* Header */}
+          <div
+            className={`flex items-center justify-between ${
+              mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
+            }`}
+          >
+            <div className="flex items-center gap-2">
+              <TitleIcon className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
+            </div>
+            <Button variant="ghost" size="sm" onClick={onClose}>
+              <X className="w-5 h-5" />
+            </Button>
+          </div>
+
+          <form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
+            {/* Archive name */}
+            <p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
+              {mode === 'reprint' ? (
+                <>
+                  Send <span className="text-white">{archiveName}</span> to a printer
+                </>
+              ) : (
+                <>
+                  <span className="block text-bambu-gray mb-1">Print Job</span>
+                  <span className="text-white font-medium truncate block">{archiveName}</span>
+                </>
+              )}
+            </p>
+
+            {/* Printer selection */}
+            <PrinterSelector
+              printers={printers || []}
+              selectedPrinterId={selectedPrinter}
+              onSelect={setSelectedPrinter}
+              isLoading={loadingPrinters}
+              allowUnassigned={mode === 'edit-queue-item'}
+            />
+
+            {/* Plate selection */}
+            <PlateSelector
+              plates={plates}
+              isMultiPlate={isMultiPlate}
+              selectedPlate={selectedPlate}
+              onSelect={setSelectedPlate}
+            />
+
+            {/* Filament mapping - show when printer selected and plate ready */}
+            {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && (
+              <FilamentMapping
+                printerId={selectedPrinter}
+                archiveId={archiveId}
+                selectedPlate={selectedPlate}
+                isMultiPlate={isMultiPlate}
+                manualMappings={manualMappings}
+                onManualMappingChange={setManualMappings}
+              />
+            )}
+
+            {/* Print options */}
+            {(mode === 'reprint' || selectedPrinter) && (
+              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
+            )}
+
+            {/* Schedule options - only for queue modes */}
+            {mode !== 'reprint' && (
+              <ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
+            )}
+
+            {/* Error message */}
+            {(reprintMutation.isError || addToQueueMutation.isError || updateQueueMutation.isError) && (
+              <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+                {((reprintMutation.error || addToQueueMutation.error || updateQueueMutation.error) as Error)?.message ||
+                  'Failed to complete operation'}
+              </div>
+            )}
+
+            {/* Actions */}
+            <div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                Cancel
+              </Button>
+              <Button
+                type="submit"
+                disabled={!canSubmit}
+                className="flex-1"
+              >
+                {isPending ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    {modalConfig.loadingText}
+                  </>
+                ) : (
+                  <>
+                    <SubmitIcon className="w-4 h-4" />
+                    {modalConfig.submitText}
+                  </>
+                )}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+// Re-export types for convenience
+export type { PrintModalProps, PrintModalMode } from './types';

+ 149 - 0
frontend/src/components/PrintModal/types.ts

@@ -0,0 +1,149 @@
+import type { PrintQueueItem, Printer } from '../../api/client';
+
+/**
+ * Mode of operation for the PrintModal.
+ * - 'reprint': Immediate print from archive (no schedule options)
+ * - 'add-to-queue': Schedule print to queue (includes schedule options)
+ * - 'edit-queue-item': Edit existing queue item (all options + existing values)
+ */
+export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
+
+/**
+ * Props for the unified PrintModal component.
+ */
+export interface PrintModalProps {
+  /** Modal operation mode */
+  mode: PrintModalMode;
+  /** Archive ID to print */
+  archiveId: number;
+  /** Archive display name */
+  archiveName: string;
+  /** Existing queue item (only for edit-queue-item mode) */
+  queueItem?: PrintQueueItem;
+  /** Handler for closing the modal */
+  onClose: () => void;
+  /** Handler for successful operation */
+  onSuccess?: () => void;
+}
+
+/**
+ * Print options that can be configured for a print job.
+ */
+export interface PrintOptions {
+  bed_levelling: boolean;
+  flow_cali: boolean;
+  vibration_cali: boolean;
+  layer_inspect: boolean;
+  timelapse: boolean;
+}
+
+/**
+ * Default print options values.
+ */
+export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
+  bed_levelling: true,
+  flow_cali: false,
+  vibration_cali: true,
+  layer_inspect: false,
+  timelapse: false,
+};
+
+/**
+ * Schedule type for queue items.
+ */
+export type ScheduleType = 'asap' | 'scheduled' | 'manual';
+
+/**
+ * Schedule options for queue items.
+ */
+export interface ScheduleOptions {
+  scheduleType: ScheduleType;
+  scheduledTime: string;
+  requirePreviousSuccess: boolean;
+  autoOffAfter: boolean;
+}
+
+/**
+ * Default schedule options values.
+ */
+export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
+  scheduleType: 'asap',
+  scheduledTime: '',
+  requirePreviousSuccess: false,
+  autoOffAfter: false,
+};
+
+/**
+ * Plate information from a multi-plate 3MF file.
+ */
+export interface PlateInfo {
+  index: number;
+  name: string | null;
+  has_thumbnail: boolean;
+  thumbnail_url: string | null;
+  objects: string[];
+  filaments: Array<{
+    type: string;
+    color: string;
+  }>;
+  print_time_seconds: number | null;
+  filament_used_grams: number | null;
+}
+
+/**
+ * Response from the archive plates API.
+ */
+export interface PlatesResponse {
+  is_multi_plate: boolean;
+  plates: PlateInfo[];
+}
+
+/**
+ * Props for the PrinterSelector component.
+ */
+export interface PrinterSelectorProps {
+  printers: Printer[];
+  selectedPrinterId: number | null;
+  onSelect: (printerId: number | null) => void;
+  isLoading?: boolean;
+  allowUnassigned?: boolean;
+}
+
+/**
+ * Props for the PlateSelector component.
+ */
+export interface PlateSelectorProps {
+  plates: PlateInfo[];
+  isMultiPlate: boolean;
+  selectedPlate: number | null;
+  onSelect: (plateIndex: number) => void;
+}
+
+/**
+ * Props for the FilamentMapping component.
+ */
+export interface FilamentMappingProps {
+  printerId: number;
+  archiveId: number;
+  selectedPlate: number | null;
+  isMultiPlate: boolean;
+  manualMappings: Record<number, number>;
+  onManualMappingChange: (mappings: Record<number, number>) => void;
+}
+
+/**
+ * Props for the PrintOptions component.
+ */
+export interface PrintOptionsProps {
+  options: PrintOptions;
+  onChange: (options: PrintOptions) => void;
+  defaultExpanded?: boolean;
+}
+
+/**
+ * Props for the ScheduleOptions component.
+ */
+export interface ScheduleOptionsProps {
+  options: ScheduleOptions;
+  onChange: (options: ScheduleOptions) => void;
+}

+ 0 - 666
frontend/src/components/ReprintModal.tsx

@@ -1,666 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings, Layers } from 'lucide-react';
-import { api } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { getColorName } from '../utils/colors';
-
-interface ReprintModalProps {
-  archiveId: number;
-  archiveName: string;
-  onClose: () => void;
-  onSuccess: () => void;
-}
-
-// Print options with defaults
-interface PrintOptions {
-  timelapse: boolean;
-  bed_levelling: boolean;
-  flow_cali: boolean;
-  vibration_cali: boolean;
-  layer_inspect: boolean;
-}
-
-const DEFAULT_PRINT_OPTIONS: PrintOptions = {
-  bed_levelling: true,
-  flow_cali: false,
-  vibration_cali: true,
-  layer_inspect: false,
-  timelapse: false,
-};
-
-// Format seconds to human readable time
-const formatTime = (seconds: number | null | undefined): string => {
-  if (!seconds) return '';
-  const hours = Math.floor(seconds / 3600);
-  const minutes = Math.floor((seconds % 3600) / 60);
-  if (hours > 0) return `${hours}h ${minutes}m`;
-  return `${minutes}m`;
-};
-
-export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
-  const queryClient = useQueryClient();
-  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
-  const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
-  const [isRefreshing, setIsRefreshing] = useState(false);
-  const [showOptions, setShowOptions] = useState(false);
-  const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
-  const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
-
-  // Clear manual mappings when printer or plate changes
-  useEffect(() => {
-    setManualMappings({});
-  }, [selectedPrinter, selectedPlate]);
-
-  // Close on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onClose();
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose]);
-
-  const { data: printers, isLoading: loadingPrinters } = useQuery({
-    queryKey: ['printers'],
-    queryFn: api.getPrinters,
-  });
-
-  // Fetch available plates from the archived 3MF
-  const { data: platesData } = useQuery({
-    queryKey: ['archive-plates', archiveId],
-    queryFn: () => api.getArchivePlates(archiveId),
-  });
-
-  // Auto-select the first plate for single-plate files, or require selection for multi-plate
-  useEffect(() => {
-    if (platesData?.plates?.length === 1) {
-      setSelectedPlate(platesData.plates[0].index);
-    }
-  }, [platesData]);
-
-  // Fetch filament requirements from the archived 3MF (filtered by plate if selected)
-  const { data: filamentReqs } = useQuery({
-    queryKey: ['archive-filaments', archiveId, selectedPlate],
-    queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
-    enabled: selectedPlate !== null || !platesData?.is_multi_plate,
-  });
-
-  // Fetch printer status when a printer is selected
-  const { data: printerStatus } = useQuery({
-    queryKey: ['printer-status', selectedPrinter],
-    queryFn: () => api.getPrinterStatus(selectedPrinter!),
-    enabled: !!selectedPrinter,
-  });
-
-  const reprintMutation = useMutation({
-    mutationFn: () => {
-      if (!selectedPrinter) throw new Error('No printer selected');
-      return api.reprintArchive(archiveId, selectedPrinter, {
-        plate_id: selectedPlate ?? undefined,
-        ams_mapping: amsMapping,
-        ...printOptions,
-      });
-    },
-    onSuccess: () => {
-      onSuccess();
-      onClose();
-    },
-  });
-
-  const activePrinters = printers?.filter((p) => p.is_active) || [];
-  const isMultiPlate = platesData?.is_multi_plate ?? false;
-  const plates = platesData?.plates ?? [];
-
-  // Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
-  const normalizeColor = (color: string | null | undefined): string => {
-    if (!color) return '#808080';
-    // Remove alpha channel if present (8-char hex to 6-char)
-    const hex = color.replace('#', '').substring(0, 6);
-    return `#${hex}`;
-  };
-
-  // Helper to format slot label for display
-  const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
-    if (isExternal) return 'External';
-    const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
-    if (isHt) return `HT-${letter}`;
-    return `AMS-${letter} Slot ${trayId + 1}`;
-  };
-
-  // Calculate global tray ID for MQTT command
-  // Regular AMS: (ams_id * 4) + slot_id, External: 254
-  const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
-    if (isExternal) return 254;
-    return amsId * 4 + trayId;
-  };
-
-  // Build a list of all loaded filaments from printer's AMS/HT/External with location info
-  const loadedFilaments = useMemo(() => {
-    const filaments: Array<{
-      type: string;
-      color: string;
-      colorName: string;
-      amsId: number;
-      trayId: number;
-      isHt: boolean;
-      isExternal: boolean;
-      label: string;
-      globalTrayId: number;
-    }> = [];
-
-    // Add filaments from all AMS units (regular and HT)
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-          });
-        }
-      });
-    });
-
-    // Add external spool if loaded
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-      });
-    }
-
-    return filaments;
-  }, [printerStatus]);
-
-  // Compare required filaments with loaded filaments
-  // Match by filament TYPE (not slot), since the printer dynamically maps slots
-  // Respects manual overrides when set
-  const filamentComparison = useMemo(() => {
-    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
-
-    // Helper to normalize color for comparison (case-insensitive, strip #)
-    const normalizeColorForCompare = (color: string | undefined): string => {
-      if (!color) return '';
-      return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
-    };
-
-    // Helper to check if two colors are similar (within threshold)
-    const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
-      const hex1 = normalizeColorForCompare(color1);
-      const hex2 = normalizeColorForCompare(color2);
-      if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
-
-      const r1 = parseInt(hex1.substring(0, 2), 16);
-      const g1 = parseInt(hex1.substring(2, 4), 16);
-      const b1 = parseInt(hex1.substring(4, 6), 16);
-      const r2 = parseInt(hex2.substring(0, 2), 16);
-      const g2 = parseInt(hex2.substring(2, 4), 16);
-      const b2 = parseInt(hex2.substring(4, 6), 16);
-
-      // Check if each RGB component is within threshold
-      return Math.abs(r1 - r2) <= threshold &&
-             Math.abs(g1 - g2) <= threshold &&
-             Math.abs(b1 - b2) <= threshold;
-    };
-
-    // Track which trays have been assigned to avoid duplicates
-    // First, mark all manually assigned trays as used
-    const usedTrayIds = new Set<number>(Object.values(manualMappings));
-
-    return filamentReqs.filaments.map((req) => {
-      const slotId = req.slot_id || 0;
-
-      // Check if there's a manual override for this slot
-      if (slotId > 0 && manualMappings[slotId] !== undefined) {
-        const manualTrayId = manualMappings[slotId];
-        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
-
-        if (manualLoaded) {
-          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-          const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-                            colorsAreSimilar(manualLoaded.color, req.color);
-
-          let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-          if (typeMatch && colorMatch) {
-            status = 'match';
-          } else if (typeMatch) {
-            status = 'type_only';
-          } else {
-            status = 'mismatch';
-          }
-
-          return {
-            ...req,
-            loaded: manualLoaded,
-            hasFilament: true,
-            typeMatch,
-            colorMatch,
-            status,
-            isManual: true,
-          };
-        }
-      }
-
-      // Auto-match: Find a loaded filament that matches by TYPE
-      // Priority: exact color match > similar color match > type-only match
-      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
-      const exactMatch = loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
-      const similarMatch = !exactMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase() &&
-               colorsAreSimilar(f.color, req.color)
-      );
-      const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
-        (f) => !usedTrayIds.has(f.globalTrayId) &&
-               f.type?.toUpperCase() === req.type?.toUpperCase()
-      );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
-
-      // Mark this tray as used so it won't be assigned to another slot
-      if (loaded) {
-        usedTrayIds.add(loaded.globalTrayId);
-      }
-
-      const hasFilament = !!loaded;
-      const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
-
-      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
-      let status: 'match' | 'type_only' | 'mismatch' | 'empty';
-      if (exactMatch || similarMatch) {
-        status = 'match';
-      } else if (typeOnlyMatch) {
-        status = 'type_only';
-      } else {
-        status = 'mismatch';
-      }
-
-      return {
-        ...req,
-        loaded,
-        hasFilament,
-        typeMatch,
-        colorMatch,
-        status,
-        isManual: false,
-      };
-    });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
-
-  // Build AMS mapping from auto-matched filaments
-  // Format: array matching 3MF filament slot structure
-  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
-  // e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
-  const amsMapping = useMemo(() => {
-    if (filamentComparison.length === 0) return undefined;
-
-    // Find the max slot_id to determine array size
-    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
-    if (maxSlotId <= 0) return undefined;
-
-    // Create array with -1 for all positions
-    const mapping = new Array(maxSlotId).fill(-1);
-
-    // Fill in tray IDs at correct positions (slot_id - 1)
-    filamentComparison.forEach((f) => {
-      if (f.slot_id && f.slot_id > 0) {
-        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
-      }
-    });
-
-    return mapping;
-  }, [filamentComparison]);
-
-  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
-
-  return (
-    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
-      <Card className="w-full max-w-lg">
-        <CardContent>
-          {/* Header */}
-          <div className="flex items-center justify-between mb-4">
-            <h2 className="text-lg font-semibold text-white">Re-print</h2>
-            <Button variant="ghost" size="sm" onClick={onClose}>
-              <X className="w-5 h-5" />
-            </Button>
-          </div>
-
-          <p className="text-sm text-bambu-gray mb-4">
-            Send <span className="text-white">{archiveName}</span> to a printer
-          </p>
-
-          {/* Printer selection */}
-          {loadingPrinters ? (
-            <div className="flex justify-center py-8">
-              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
-            </div>
-          ) : activePrinters.length === 0 ? (
-            <div className="text-center py-8 text-bambu-gray">
-              No active printers available
-            </div>
-          ) : (
-            <div className="space-y-2 mb-6">
-              {activePrinters.map((printer) => (
-                <button
-                  key={printer.id}
-                  onClick={() => setSelectedPrinter(printer.id)}
-                  className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
-                    selectedPrinter === printer.id
-                      ? 'border-bambu-green bg-bambu-green/10'
-                      : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                  }`}
-                >
-                  <div
-                    className={`p-2 rounded-lg ${
-                      selectedPrinter === printer.id
-                        ? 'bg-bambu-green/20'
-                        : 'bg-bambu-dark-tertiary'
-                    }`}
-                  >
-                    <Printer
-                      className={`w-5 h-5 ${
-                        selectedPrinter === printer.id
-                          ? 'text-bambu-green'
-                          : 'text-bambu-gray'
-                      }`}
-                    />
-                  </div>
-                  <div className="text-left">
-                    <p className="text-white font-medium">{printer.name}</p>
-                    <p className="text-xs text-bambu-gray">
-                      {printer.model || 'Unknown model'} • {printer.ip_address}
-                    </p>
-                  </div>
-                </button>
-              ))}
-            </div>
-          )}
-
-          {/* Plate selection - show when multi-plate file detected */}
-          {isMultiPlate && plates.length > 1 && (
-            <div className="mb-4">
-              <div className="flex items-center gap-2 mb-2">
-                <Layers className="w-4 h-4 text-bambu-gray" />
-                <span className="text-sm text-bambu-gray">Select Plate to Print</span>
-                {!selectedPlate && (
-                  <span className="text-xs text-orange-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Selection required
-                  </span>
-                )}
-              </div>
-              <div className="grid grid-cols-2 gap-2">
-                {plates.map((plate) => (
-                  <button
-                    key={plate.index}
-                    onClick={() => setSelectedPlate(plate.index)}
-                    className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-                      selectedPlate === plate.index
-                        ? 'border-bambu-green bg-bambu-green/10'
-                        : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-                    }`}
-                  >
-                    {plate.has_thumbnail && plate.thumbnail_url ? (
-                      <img
-                        src={plate.thumbnail_url}
-                        alt={`Plate ${plate.index}`}
-                        className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-                      />
-                    ) : (
-                      <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                        <Layers className="w-5 h-5 text-bambu-gray" />
-                      </div>
-                    )}
-                    <div className="min-w-0 flex-1">
-                      <p className="text-sm text-white font-medium truncate">
-                        {plate.name || `Plate ${plate.index}`}
-                      </p>
-                      <p className="text-xs text-bambu-gray truncate">
-                        {plate.objects?.length > 0
-                          ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
-                          : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                        {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
-                      </p>
-                    </div>
-                    {selectedPlate === plate.index && (
-                      <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                    )}
-                  </button>
-                ))}
-              </div>
-            </div>
-          )}
-
-          {/* Filament comparison - show when printer selected and has filament requirements */}
-          {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && filamentComparison.length > 0 && (
-            <div className="mb-4">
-              <div className="flex items-center gap-2 mb-2">
-                <span className="text-sm text-bambu-gray">Filament Check</span>
-                <button
-                  onClick={async () => {
-                    if (!selectedPrinter) return;
-                    setIsRefreshing(true);
-                    try {
-                      // Request fresh data from printer via MQTT pushall command
-                      await api.refreshPrinterStatus(selectedPrinter);
-                      // Wait a moment for printer to respond, then refetch
-                      await new Promise((r) => setTimeout(r, 500));
-                      await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
-                    } finally {
-                      setIsRefreshing(false);
-                    }
-                  }}
-                  className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                  title="Re-read AMS status from printer"
-                  disabled={isRefreshing}
-                >
-                  <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
-                  <span>Re-read</span>
-                </button>
-                {hasTypeMismatch ? (
-                  <span className="text-xs text-orange-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Type not found
-                  </span>
-                ) : filamentComparison.some((f) => f.status === 'type_only') ? (
-                  <span className="text-xs text-yellow-400 flex items-center gap-1">
-                    <AlertTriangle className="w-3 h-3" />
-                    Color mismatch
-                  </span>
-                ) : (
-                  <span className="text-xs text-bambu-green flex items-center gap-1">
-                    <Check className="w-3 h-3" />
-                    Ready
-                  </span>
-                )}
-              </div>
-              <div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
-                {filamentComparison.map((item, idx) => (
-                  <div
-                    key={idx}
-                    className="grid items-center gap-2"
-                    style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-                  >
-                    {/* Required color */}
-                    <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                      <Circle
-                        className="w-3 h-3 flex-shrink-0"
-                        fill={item.color}
-                        stroke={item.color}
-                      />
-                    </span>
-                    {/* Required type + grams */}
-                    <span className="text-white truncate">
-                      {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-                    </span>
-                    {/* Arrow */}
-                    <span className="text-bambu-gray">→</span>
-                    {/* Slot selector dropdown */}
-                    <select
-                      value={item.loaded?.globalTrayId ?? ''}
-                      onChange={(e) => {
-                        const slotId = item.slot_id || 0;
-                        if (slotId > 0) {
-                          const value = e.target.value;
-                          if (value === '') {
-                            // Clear manual override
-                            setManualMappings((prev) => {
-                              const next = { ...prev };
-                              delete next[slotId];
-                              return next;
-                            });
-                          } else {
-                            setManualMappings((prev) => ({
-                              ...prev,
-                              [slotId]: parseInt(value, 10),
-                            }));
-                          }
-                        }
-                      }}
-                      className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                        item.status === 'match'
-                          ? 'border-bambu-green/50 text-bambu-green'
-                          : item.status === 'type_only'
-                          ? 'border-yellow-400/50 text-yellow-400'
-                          : 'border-orange-400/50 text-orange-400'
-                      } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                      title={item.isManual ? 'Manually selected' : 'Auto-matched'}
-                    >
-                      <option value="" className="bg-bambu-dark text-bambu-gray">
-                        -- Select slot --
-                      </option>
-                      {loadedFilaments.map((f) => (
-                        <option
-                          key={f.globalTrayId}
-                          value={f.globalTrayId}
-                          className="bg-bambu-dark text-white"
-                        >
-                          {f.label}: {f.type} ({f.colorName})
-                        </option>
-                      ))}
-                    </select>
-                    {/* Status icon */}
-                    {item.status === 'match' ? (
-                      <Check className="w-3 h-3 text-bambu-green" />
-                    ) : item.status === 'type_only' ? (
-                      <span title="Same type, different color">
-                        <AlertTriangle className="w-3 h-3 text-yellow-400" />
-                      </span>
-                    ) : (
-                      <span title="Filament type not loaded">
-                        <AlertTriangle className="w-3 h-3 text-orange-400" />
-                      </span>
-                    )}
-                  </div>
-                ))}
-              </div>
-              {hasTypeMismatch && (
-                <p className="text-xs text-orange-400 mt-2">
-                  Required filament type not found in printer.
-                </p>
-              )}
-            </div>
-          )}
-
-          {/* Print Options */}
-          {selectedPrinter && (
-            <div className="mb-4">
-              <button
-                onClick={() => setShowOptions(!showOptions)}
-                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
-              >
-                <Settings className="w-4 h-4" />
-                <span>Print Options</span>
-                {showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
-              </button>
-              {showOptions && (
-                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-                  {[
-                    { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
-                    { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
-                    { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
-                    { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
-                    { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
-                  ].map(({ key, label, desc }) => (
-                    <label key={key} className="flex items-center justify-between cursor-pointer group">
-                      <div>
-                        <span className="text-sm text-white">{label}</span>
-                        <p className="text-xs text-bambu-gray">{desc}</p>
-                      </div>
-                      <div
-                        className={`relative w-10 h-5 rounded-full transition-colors ${
-                          printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
-                        }`}
-                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
-                      >
-                        <div
-                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                            printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
-                          }`}
-                        />
-                      </div>
-                    </label>
-                  ))}
-                </div>
-              )}
-            </div>
-          )}
-
-          {/* Error message */}
-          {reprintMutation.isError && (
-            <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
-              {(reprintMutation.error as Error).message || 'Failed to start print'}
-            </div>
-          )}
-
-          {/* Actions */}
-          <div className="flex gap-3">
-            <Button variant="secondary" onClick={onClose} className="flex-1">
-              Cancel
-            </Button>
-            <Button
-              onClick={() => reprintMutation.mutate()}
-              disabled={!selectedPrinter || (isMultiPlate && !selectedPlate) || reprintMutation.isPending}
-              className="flex-1"
-            >
-              {reprintMutation.isPending ? (
-                <>
-                  <Loader2 className="w-4 h-4 animate-spin" />
-                  Sending...
-                </>
-              ) : (
-                <>
-                  <Printer className="w-4 h-4" />
-                  Print
-                </>
-              )}
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 269 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -0,0 +1,269 @@
+import { useMemo } from 'react';
+import { getColorName } from '../utils/colors';
+import {
+  normalizeColor,
+  normalizeColorForCompare,
+  colorsAreSimilar,
+  formatSlotLabel,
+  getGlobalTrayId,
+} from '../utils/amsHelpers';
+import type { PrinterStatus } from '../api/client';
+
+/**
+ * Represents a loaded filament in the printer's AMS/HT/External spool holder.
+ */
+export interface LoadedFilament {
+  type: string;
+  color: string;
+  colorName: string;
+  amsId: number;
+  trayId: number;
+  isHt: boolean;
+  isExternal: boolean;
+  label: string;
+  globalTrayId: number;
+}
+
+/**
+ * Represents a required filament from the 3MF file.
+ */
+export interface FilamentRequirement {
+  slot_id: number;
+  type: string;
+  color: string;
+  used_grams: number;
+}
+
+/**
+ * Status of filament comparison between required and loaded.
+ */
+export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
+
+/**
+ * Result of comparing a required filament with loaded filaments.
+ */
+export interface FilamentComparison extends FilamentRequirement {
+  loaded: LoadedFilament | undefined;
+  hasFilament: boolean;
+  typeMatch: boolean;
+  colorMatch: boolean;
+  status: FilamentStatus;
+  isManual: boolean;
+}
+
+interface FilamentRequirementsResponse {
+  filaments: FilamentRequirement[];
+}
+
+interface UseFilamentMappingResult {
+  /** List of all filaments loaded in the printer */
+  loadedFilaments: LoadedFilament[];
+  /** Comparison results for each required filament */
+  filamentComparison: FilamentComparison[];
+  /** AMS mapping array for the print command */
+  amsMapping: number[] | undefined;
+  /** Whether any required filament type is not loaded */
+  hasTypeMismatch: boolean;
+  /** Whether any required filament has a color mismatch */
+  hasColorMismatch: boolean;
+}
+
+/**
+ * Hook to build loaded filaments list from printer status.
+ * Extracts filaments from all AMS units (regular and HT) and external spool.
+ */
+export function useLoadedFilaments(
+  printerStatus: PrinterStatus | undefined
+): LoadedFilament[] {
+  return useMemo(() => {
+    const filaments: LoadedFilament[] = [];
+
+    // Add filaments from all AMS units (regular and HT)
+    printerStatus?.ams?.forEach((amsUnit) => {
+      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
+      amsUnit.tray.forEach((tray) => {
+        if (tray.tray_type) {
+          const color = normalizeColor(tray.tray_color);
+          filaments.push({
+            type: tray.tray_type,
+            color,
+            colorName: getColorName(color),
+            amsId: amsUnit.id,
+            trayId: tray.id,
+            isHt,
+            isExternal: false,
+            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          });
+        }
+      });
+    });
+
+    // Add external spool if loaded
+    if (printerStatus?.vt_tray?.tray_type) {
+      const color = normalizeColor(printerStatus.vt_tray.tray_color);
+      filaments.push({
+        type: printerStatus.vt_tray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: 0,
+        isHt: false,
+        isExternal: true,
+        label: 'External',
+        globalTrayId: 254,
+      });
+    }
+
+    return filaments;
+  }, [printerStatus]);
+}
+
+/**
+ * Hook to compare required filaments with loaded filaments and build AMS mapping.
+ * Handles both auto-matching and manual overrides.
+ *
+ * @param filamentReqs - Required filaments from the 3MF file
+ * @param printerStatus - Current printer status with AMS information
+ * @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
+ */
+export function useFilamentMapping(
+  filamentReqs: FilamentRequirementsResponse | undefined,
+  printerStatus: PrinterStatus | undefined,
+  manualMappings: Record<number, number>
+): UseFilamentMappingResult {
+  const loadedFilaments = useLoadedFilaments(printerStatus);
+
+  const filamentComparison = useMemo(() => {
+    if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
+
+    // Track which trays have been assigned to avoid duplicates
+    // First, mark all manually assigned trays as used
+    const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+    return filamentReqs.filaments.map((req) => {
+      const slotId = req.slot_id || 0;
+
+      // Check if there's a manual override for this slot
+      if (slotId > 0 && manualMappings[slotId] !== undefined) {
+        const manualTrayId = manualMappings[slotId];
+        const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+        if (manualLoaded) {
+          const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+          const colorMatch =
+            normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+            colorsAreSimilar(manualLoaded.color, req.color);
+
+          let status: FilamentStatus;
+          if (typeMatch && colorMatch) {
+            status = 'match';
+          } else if (typeMatch) {
+            status = 'type_only';
+          } else {
+            status = 'mismatch';
+          }
+
+          return {
+            ...req,
+            loaded: manualLoaded,
+            hasFilament: true,
+            typeMatch,
+            colorMatch,
+            status,
+            isManual: true,
+          };
+        }
+      }
+
+      // Auto-match: Find a loaded filament that matches by TYPE
+      // Priority: exact color match > similar color match > type-only match
+      // IMPORTANT: Exclude trays that are already assigned (manually or auto)
+      const exactMatch = loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch =
+        !exactMatch &&
+        loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+      const typeOnlyMatch =
+        !exactMatch &&
+        !similarMatch &&
+        loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        );
+      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+
+      // Mark this tray as used so it won't be assigned to another slot
+      if (loaded) {
+        usedTrayIds.add(loaded.globalTrayId);
+      }
+
+      const hasFilament = !!loaded;
+      const typeMatch = hasFilament;
+      const colorMatch = !!exactMatch || !!similarMatch;
+
+      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
+      let status: FilamentStatus;
+      if (exactMatch || similarMatch) {
+        status = 'match';
+      } else if (typeOnlyMatch) {
+        status = 'type_only';
+      } else {
+        status = 'mismatch';
+      }
+
+      return {
+        ...req,
+        loaded,
+        hasFilament,
+        typeMatch,
+        colorMatch,
+        status,
+        isManual: false,
+      };
+    });
+  }, [filamentReqs, loadedFilaments, manualMappings]);
+
+  // Build AMS mapping from matched filaments
+  // Format: array matching 3MF filament slot structure
+  // Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
+  const amsMapping = useMemo(() => {
+    if (filamentComparison.length === 0) return undefined;
+
+    // Find the max slot_id to determine array size
+    const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
+    if (maxSlotId <= 0) return undefined;
+
+    // Create array with -1 for all positions
+    const mapping = new Array(maxSlotId).fill(-1);
+
+    // Fill in tray IDs at correct positions (slot_id - 1)
+    filamentComparison.forEach((f) => {
+      if (f.slot_id && f.slot_id > 0) {
+        mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
+      }
+    });
+
+    return mapping;
+  }, [filamentComparison]);
+
+  const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
+  const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
+
+  return {
+    loadedFilaments,
+    filamentComparison,
+    amsMapping,
+    hasTypeMismatch,
+    hasColorMismatch,
+  };
+}

+ 9 - 10
frontend/src/pages/ArchivesPage.tsx

@@ -50,7 +50,7 @@ import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ModelViewerModal } from '../components/ModelViewerModal';
-import { ReprintModal } from '../components/ReprintModal';
+import { PrintModal } from '../components/PrintModal';
 import { UploadModal } from '../components/UploadModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
@@ -62,7 +62,6 @@ import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
 import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
-import { AddToQueueModal } from '../components/AddToQueueModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { useToast } from '../contexts/ToastContext';
@@ -882,13 +881,11 @@ function ArchiveCard({
 
       {/* Reprint Modal */}
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {
-            // Could show a toast notification here
-          }}
         />
       )}
 
@@ -1056,7 +1053,8 @@ function ArchiveCard({
       )}
 
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}
@@ -1630,11 +1628,11 @@ function ArchiveListRow({
 
       {/* Reprint Modal */}
       {showReprint && (
-        <ReprintModal
+        <PrintModal
+          mode="reprint"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowReprint(false)}
-          onSuccess={() => {}}
         />
       )}
 
@@ -1790,7 +1788,8 @@ function ArchiveListRow({
 
       {/* Schedule Modal */}
       {showSchedule && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={archive.id}
           archiveName={archive.print_name || archive.filename}
           onClose={() => setShowSchedule(false)}

+ 5 - 3
frontend/src/pages/FileManagerPage.tsx

@@ -839,13 +839,15 @@ export function FileManagerPage() {
   const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
 
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
+  // Note: Only depend on searchParams, not selectedFolderId, to avoid resetting when user clicks folders
   useEffect(() => {
     const folderParam = searchParams.get('folder');
-    const newFolderId = folderParam ? parseInt(folderParam, 10) : null;
-    if (newFolderId !== selectedFolderId) {
+    if (folderParam) {
+      const newFolderId = parseInt(folderParam, 10);
       setSelectedFolderId(newFolderId);
     }
-  }, [searchParams, selectedFolderId]);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [searchParams]);
 
   // Queries
   const { data: settings } = useQuery({

+ 8 - 5
frontend/src/pages/QueuePage.tsx

@@ -48,8 +48,7 @@ import type { PrintQueueItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
-import { EditQueueItemModal } from '../components/EditQueueItemModal';
-import { AddToQueueModal } from '../components/AddToQueueModal';
+import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatDuration(seconds: number | null | undefined): string {
@@ -802,15 +801,19 @@ export function QueuePage() {
 
       {/* Edit Modal */}
       {editItem && (
-        <EditQueueItemModal
-          item={editItem}
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={editItem.archive_id}
+          archiveName={editItem.archive_name || `Archive #${editItem.archive_id}`}
+          queueItem={editItem}
           onClose={() => setEditItem(null)}
         />
       )}
 
       {/* Re-queue Modal */}
       {requeueItem && (
-        <AddToQueueModal
+        <PrintModal
+          mode="add-to-queue"
           archiveId={requeueItem.archive_id}
           archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
           onClose={() => setRequeueItem(null)}

+ 124 - 0
frontend/src/utils/amsHelpers.ts

@@ -0,0 +1,124 @@
+/**
+ * AMS (Automatic Material System) helper utilities for Bambu Lab printers.
+ * These functions handle color normalization, slot labeling, and tray ID calculations
+ * for AMS, AMS-HT, and external spool configurations.
+ */
+
+/**
+ * Normalize color format from various sources.
+ * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
+ * This normalizes to "#RRGGBB" format.
+ */
+export function normalizeColor(color: string | null | undefined): string {
+  if (!color) return '#808080';
+  // Remove alpha channel if present (8-char hex to 6-char)
+  const hex = color.replace('#', '').substring(0, 6);
+  return `#${hex}`;
+}
+
+/**
+ * Normalize color for comparison (case-insensitive, strip hash and alpha).
+ */
+export function normalizeColorForCompare(color: string | undefined): string {
+  if (!color) return '';
+  return color.replace('#', '').toLowerCase().substring(0, 6);
+}
+
+/**
+ * Check if two colors are visually similar within a threshold.
+ * Uses RGB component comparison with configurable tolerance.
+ * @param color1 - First hex color
+ * @param color2 - Second hex color
+ * @param threshold - Maximum difference per RGB component (default: 40)
+ */
+export function colorsAreSimilar(
+  color1: string | undefined,
+  color2: string | undefined,
+  threshold = 40
+): boolean {
+  const hex1 = normalizeColorForCompare(color1);
+  const hex2 = normalizeColorForCompare(color2);
+  if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
+
+  const r1 = parseInt(hex1.substring(0, 2), 16);
+  const g1 = parseInt(hex1.substring(2, 4), 16);
+  const b1 = parseInt(hex1.substring(4, 6), 16);
+  const r2 = parseInt(hex2.substring(0, 2), 16);
+  const g2 = parseInt(hex2.substring(2, 4), 16);
+  const b2 = parseInt(hex2.substring(4, 6), 16);
+
+  return (
+    Math.abs(r1 - r2) <= threshold &&
+    Math.abs(g1 - g2) <= threshold &&
+    Math.abs(b1 - b2) <= threshold
+  );
+}
+
+/**
+ * Format slot label for display in the UI.
+ * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
+ * @param trayId - Tray/slot ID within the AMS unit (0-3)
+ * @param isHt - Whether this is an AMS-HT unit (single tray)
+ * @param isExternal - Whether this is the external spool holder
+ */
+export function formatSlotLabel(
+  amsId: number,
+  trayId: number,
+  isHt: boolean,
+  isExternal: boolean
+): string {
+  if (isExternal) return 'External';
+  // Convert AMS ID to letter (A, B, C, D)
+  // AMS-HT uses IDs starting at 128
+  const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
+  if (isHt) return `HT-${letter}`;
+  return `AMS-${letter} Slot ${trayId + 1}`;
+}
+
+/**
+ * Calculate global tray ID for MQTT command.
+ * Used in the ams_mapping array sent to the printer.
+ * @param amsId - AMS unit ID
+ * @param trayId - Tray/slot ID within the AMS unit
+ * @param isExternal - Whether this is the external spool holder
+ * @returns Global tray ID (0-15 for AMS, 254 for external)
+ */
+export function getGlobalTrayId(
+  amsId: number,
+  trayId: number,
+  isExternal: boolean
+): number {
+  if (isExternal) return 254;
+  return amsId * 4 + trayId;
+}
+
+/**
+ * Format seconds to human readable time string.
+ */
+export function formatTime(seconds: number | null | undefined): string {
+  if (!seconds) return '';
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  if (hours > 0) return `${hours}h ${minutes}m`;
+  return `${minutes}m`;
+}
+
+/**
+ * Get minimum datetime for scheduling (now + 1 minute).
+ * Returns ISO string format for datetime-local input.
+ */
+export function getMinDateTime(): string {
+  const now = new Date();
+  now.setMinutes(now.getMinutes() + 1);
+  return now.toISOString().slice(0, 16);
+}
+
+/**
+ * Check if a scheduled time is a placeholder far-future date.
+ * Placeholder dates (more than 6 months out) are treated as ASAP.
+ */
+export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
+  if (!scheduledTime) return false;
+  const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
+  return new Date(scheduledTime).getTime() > sixMonthsFromNow;
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DuPkbmqh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-qwbvIwuo.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DuPkbmqh.js"></script>
+    <script type="module" crossorigin src="/assets/index-qwbvIwuo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff