| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- 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 } 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,
- };
- export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
- const queryClient = useQueryClient();
- const [selectedPrinter, setSelectedPrinter] = 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 changes
- useEffect(() => {
- setManualMappings({});
- }, [selectedPrinter]);
- // 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 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', selectedPrinter],
- queryFn: () => api.getPrinterStatus(selectedPrinter!),
- enabled: !!selectedPrinter,
- });
- const reprintMutation = useMutation({
- mutationFn: () => {
- if (!selectedPrinter) throw new Error('No printer selected');
- return api.reprintArchive(archiveId, selectedPrinter, {
- ams_mapping: amsMapping,
- ...printOptions,
- });
- },
- onSuccess: () => {
- onSuccess();
- onClose();
- },
- });
- const activePrinters = printers?.filter((p) => p.is_active) || [];
- // 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>
- )}
- {/* Filament comparison - show when printer selected and has filament requirements */}
- {selectedPrinter && 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 || 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>
- );
- }
|