mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
SpoolBuddy said "Unknown color" under a correctly-coloured swatch for spools the inventory page names without trouble. The name was never in the spool record. Bambu's RFID tags frequently carry no readable colour name -- some carry an internal code instead -- so Bambuddy has always resolved the swatch's own hex against the colour catalog, and the kiosk was rendering the empty column. Exactly one SpoolBuddy file already did it right, which is what marks this as an inconsistency rather than a kiosk simplification. Route every SpoolBuddy colour-name display through resolveSpoolColorName, which also stops the spools that do carry a code from showing "A06-D0" at the user. The write-tag edit form keeps the raw stored value on purpose: offering a derived name for editing invites the user to save it as though they had typed it. Spoolman has no colour-name field at all, so _map_spoolman_spool puts the spool's subtype there and sets color_name_is_synthesized. That flag now travels on the tag-matched broadcast, and resolveSpoolColorName takes a third argument to honour it -- a synthesised name loses to the catalog and survives only as a last resort. Spoolman installs were reading "Silk+" as a colour on the Inventory page and the AMS hover card too, so those call sites pass the flag as well. Searching by a colour you can read on screen now finds it, in the kiosk and in Bambuddy: the shared inventory filter matches the resolved name as well as the stored one. That makes the filter depend on the catalog, which loads asynchronously, so the three memoised call sites take its version as a dependency -- without that, a query typed before the catalog arrives keeps its empty result and reproduces the very symptom being fixed. The fallback label was hardcoded English in components that already import useTranslation; it is now spoolbuddy.spool.unknownColor in all 14
654 lines
30 KiB
TypeScript
654 lines
30 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { X, Loader2, Package, Search } from 'lucide-react';
|
|
import { api } from '../api/client';
|
|
import type { InventorySpool, SpoolAssignment } from '../api/client';
|
|
import { Button } from './Button';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { filterSpoolsByQuery } from '../utils/inventorySearch';
|
|
import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
|
|
|
|
interface AssignSpoolModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
printerId: number;
|
|
amsId: number;
|
|
trayId: number;
|
|
trayInfo?: {
|
|
type: string;
|
|
material?: string;
|
|
profile?: string;
|
|
color: string;
|
|
location: string;
|
|
};
|
|
spoolmanEnabled?: boolean;
|
|
}
|
|
|
|
export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [disableFiltering, setDisableFiltering] = useState(false);
|
|
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
|
|
const [selectedSpoolmanSpoolId, setSelectedSpoolmanSpoolId] = useState<number | null>(null);
|
|
useEffect(() => {
|
|
setSelectedSpoolId(null);
|
|
setSelectedSpoolmanSpoolId(null);
|
|
}, [disableFiltering]);
|
|
const [searchFilter, setSearchFilter] = useState('');
|
|
const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
|
|
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
|
|
// Profile-only mismatch no longer triggers the popup — the backend's
|
|
// `apply_spool_to_slot_via_mqtt` pushes the spool's slicer profile to the
|
|
// AMS slot on every assign anyway, so warning the user about a profile
|
|
// delta then "fixing" it during the same action was friction without
|
|
// benefit (#1552). Material mismatch still warns because the firmware can
|
|
// refuse a print when type doesn't match; combined material+profile
|
|
// mismatches keep the profile detail in the same popup as the material
|
|
// warning.
|
|
const [mismatchDetails, setMismatchDetails] = useState<{
|
|
type: 'material' | 'partial' | 'material_profile' | 'partial_profile';
|
|
spoolMaterial: string;
|
|
trayMaterial: string;
|
|
spoolProfile?: string;
|
|
trayProfile?: string;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setDisableFiltering(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Unique cache key — different consumers of `['inventory-spools']` call
|
|
// `getSpools()` with different `includeArchived` arguments (InventoryPage:
|
|
// true, SpoolBuddyDashboard / SpoolBuddyInventoryPage: false), but they
|
|
// all share the same key. React Query treats them as one query and
|
|
// serves whichever response landed first, so a SpoolBuddy component
|
|
// priming the cache with the archived-excluded payload makes the picker
|
|
// miss spools that *are* archived OR (more subtly) miss any spool that
|
|
// wasn't yet present when SpoolBuddy ran its initial fetch. The picker
|
|
// gets its own key + a fetch-everything call so this consumer is never
|
|
// at the mercy of someone else's cache state. Archived spools are then
|
|
// explicitly excluded client-side because the backend rejects archived
|
|
// assignments with HTTP 400 anyway, so listing them would only let the
|
|
// user click a button that fails.
|
|
const { data: spools, isLoading } = useQuery({
|
|
queryKey: ['inventory-spools', 'assign-modal'],
|
|
queryFn: () => api.getSpools(true),
|
|
enabled: isOpen && !spoolmanEnabled,
|
|
});
|
|
|
|
const { data: assignments } = useQuery({
|
|
queryKey: ['spool-assignments'],
|
|
queryFn: () => api.getAssignments(),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
const { data: settings } = useQuery({
|
|
queryKey: ['settings'],
|
|
queryFn: () => api.getSettings(),
|
|
enabled: isOpen,
|
|
});
|
|
|
|
const { data: spoolmanSpools, isLoading: spoolmanLoading } = useQuery({
|
|
queryKey: ['spoolman-inventory-spools', 'assign-modal'],
|
|
queryFn: () => api.getSpoolmanInventorySpools(false),
|
|
enabled: isOpen && !!spoolmanEnabled,
|
|
});
|
|
|
|
// Spoolman SlotAssignments across all printers — used to filter out spools
|
|
// already bound to another slot. Without this filter the modal offers spools
|
|
// that are already in use elsewhere (e.g. an h2d-1 slot's spool appearing
|
|
// in the x1c-2 assign list), and assigning would silently steal it from
|
|
// the other printer's slot.
|
|
const { data: allSpoolmanAssignments } = useQuery({
|
|
queryKey: ['spoolman-slot-assignments-all'],
|
|
queryFn: () => api.getSpoolmanSlotAssignments(),
|
|
enabled: isOpen && !!spoolmanEnabled,
|
|
});
|
|
|
|
// ids of spools already in some Spoolman slot — excluding the current slot
|
|
// (so a user could in theory re-pick the same spool, though the modal is
|
|
// typically only opened from empty slots).
|
|
const assignedSpoolmanSpoolIds = useMemo(() => {
|
|
if (!allSpoolmanAssignments) return new Set<number>();
|
|
return new Set(
|
|
allSpoolmanAssignments
|
|
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
|
|
.map(a => a.spoolman_spool_id),
|
|
);
|
|
}, [allSpoolmanAssignments, printerId, amsId, trayId]);
|
|
|
|
// #1414: nudge the printer to republish its state after we assign a
|
|
// spool. The backend assign-spool path already issues an MQTT command,
|
|
// but firmware (especially A1 mini external slots and any non-RFID
|
|
// assignment) doesn't always echo the new tray state back on its own,
|
|
// so the printer card sits on stale data and the user has to press
|
|
// Force-refresh to see the assignment. Calling /refresh-status forces
|
|
// a pushall the way the Force-refresh button does. Failures are
|
|
// intentionally swallowed — the assignment itself succeeded; if the
|
|
// refresh is offline the next poll / websocket update will catch up.
|
|
const nudgePrinterRepublish = () => {
|
|
api.refreshPrinterStatus(printerId).catch(() => {});
|
|
queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
|
|
};
|
|
|
|
const assignMutation = useMutation({
|
|
mutationFn: (spoolId: number) =>
|
|
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
|
|
onSuccess: (newAssignment) => {
|
|
// Immediately update cache so UI reflects the new assignment without waiting for refetch
|
|
queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
|
|
const filtered = (old || []).filter(a =>
|
|
!(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
|
|
);
|
|
filtered.push(newAssignment);
|
|
return filtered;
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
|
nudgePrinterRepublish();
|
|
// When the AMS slot was empty at assign time (tray_state ∈ {9, 10}), the
|
|
// backend persists the assignment but deliberately skips the MQTT
|
|
// `ams_filament_setting` push because Bambu firmware drops it silently
|
|
// for empty slots. `on_ams_change` re-fires the configuration once a
|
|
// spool is detected in the slot (#1680). The success-but-pending case
|
|
// gets a distinct toast so the user understands the slot hasn't been
|
|
// configured on the printer yet — saying "AMS slot configured" reads
|
|
// as a lie in that state. Mirror of `spoolbuddy/AssignToAmsModal.tsx`,
|
|
// which has handled this since the SpoolBuddy assign flow shipped.
|
|
const toastKey = newAssignment.pending_config
|
|
? 'inventory.assignPendingInsert'
|
|
: 'inventory.assignSuccess';
|
|
showToast(t(toastKey), 'success');
|
|
setShowMismatchConfirm(false);
|
|
setPendingAssignId(null);
|
|
setMismatchDetails(null);
|
|
onClose();
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
|
|
},
|
|
});
|
|
|
|
const assignSpoolmanMutation = useMutation({
|
|
mutationFn: (spoolmanSpoolId: number) =>
|
|
api.assignSpoolmanSlot({
|
|
spoolman_spool_id: spoolmanSpoolId,
|
|
printer_id: printerId,
|
|
ams_id: amsId,
|
|
tray_id: trayId,
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
|
|
nudgePrinterRepublish();
|
|
showToast(t('inventory.assignSuccess'), 'success');
|
|
onClose();
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
|
|
},
|
|
});
|
|
|
|
// --- Material/profile mismatch logic ---
|
|
const normalizeValue = (value: string | undefined | null) =>
|
|
(value ?? '').trim().toUpperCase();
|
|
|
|
const checkMaterialMatch = (
|
|
spoolMaterial: string | undefined | null,
|
|
trayMaterial: string | undefined | null
|
|
): 'exact' | 'partial' | 'none' => {
|
|
const normalizedSpool = normalizeValue(spoolMaterial);
|
|
const normalizedTray = normalizeValue(trayMaterial);
|
|
|
|
if (!normalizedSpool || !normalizedTray) return 'none';
|
|
if (normalizedSpool === normalizedTray) return 'exact';
|
|
if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
|
|
return 'partial';
|
|
}
|
|
|
|
return 'none';
|
|
};
|
|
|
|
// Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after
|
|
// `@` (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"), while the tray's
|
|
// profile is typically the bare base name. Strip the qualifier before comparing so identical
|
|
// base profiles don't trigger a mismatch warning (#1047).
|
|
const stripProfileQualifier = (value: string) => value.split('@')[0].trim();
|
|
|
|
const checkProfileMatch = (
|
|
spoolProfile: string | undefined | null,
|
|
trayProfile: string | undefined | null
|
|
): boolean => {
|
|
const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile));
|
|
const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile));
|
|
|
|
if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
|
|
|
|
return normalizedSpoolProfile === normalizedTrayProfile;
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
// Filter out spools already assigned to other slots
|
|
const assignedSpoolIds = new Set(
|
|
(assignments || [])
|
|
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
|
|
.map(a => a.spool_id)
|
|
);
|
|
// Show every spool that isn't already taken by another slot — including
|
|
// RFID-tagged Bambu Lab spools (#1133). The earlier "manual spools only"
|
|
// gate (tag_uid && tray_uuid both null) blocked the workflow where a
|
|
// user has a Bambu Lab spool in inventory but doesn't want to scan it
|
|
// via SpoolBuddy NFC every time and just wants to pick it from the list.
|
|
// External slots (amsId 254/255) have always been allowed to pick from
|
|
// any spool because the slot itself has no RFID reader; that
|
|
// distinction collapses now that AMS slots also accept any spool.
|
|
//
|
|
// The "Show all spools" toggle (disableFiltering) bypasses BOTH this
|
|
// gate and the material/profile filter below, making it a real escape
|
|
// hatch for cases where MQTT has auto-reassigned a spool to another
|
|
// slot a fraction of a second after a manual unassign — without this,
|
|
// the toggle's label is a lie ("Show all" but actually filters by
|
|
// assignment). The backend's assign_spool route is upsert-per-
|
|
// (printer, ams, tray), so picking a spool that's currently taken by
|
|
// a different slot creates a second assignment row; that's a foot-gun
|
|
// for normal flows but exactly the recovery path the toggle is for.
|
|
const availableSpools = spools?.filter((spool: InventorySpool) =>
|
|
!spool.archived_at &&
|
|
(disableFiltering || !assignedSpoolIds.has(spool.id))
|
|
);
|
|
|
|
// Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.
|
|
// Show a spool if EITHER the slicer profile matches exactly OR the material overlaps with the
|
|
// tray's material (partial-match both directions — "PLA" spool accepts a "PLA Basic" slot and
|
|
// vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating
|
|
// on strict profile equality alone hid them even when the material matched (#1047).
|
|
let filteredSpools = availableSpools;
|
|
if (!disableFiltering) {
|
|
const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));
|
|
const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);
|
|
if (trayProfile || trayMaterial) {
|
|
filteredSpools = filteredSpools?.filter((spool: InventorySpool) => {
|
|
const spoolProfile = stripProfileQualifier(normalizeValue(spool.slicer_filament_name || spool.slicer_filament));
|
|
const spoolMaterial = normalizeValue(spool.material);
|
|
if (trayProfile && spoolProfile && spoolProfile === trayProfile) return true;
|
|
if (trayMaterial && spoolMaterial) {
|
|
return (
|
|
spoolMaterial === trayMaterial ||
|
|
trayMaterial.includes(spoolMaterial) ||
|
|
spoolMaterial.includes(trayMaterial)
|
|
);
|
|
}
|
|
// Neither side has filterable info on whatever dimension remains — show it.
|
|
return !spoolProfile && !spoolMaterial;
|
|
});
|
|
}
|
|
}
|
|
if (searchFilter && filteredSpools) {
|
|
filteredSpools = filterSpoolsByQuery(filteredSpools, searchFilter);
|
|
}
|
|
|
|
const handleAssign = () => {
|
|
if (selectedSpoolmanSpoolId !== null) {
|
|
assignSpoolmanMutation.mutate(selectedSpoolmanSpoolId);
|
|
return;
|
|
}
|
|
if (!selectedSpoolId) return;
|
|
const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
|
|
if (!selectedSpool) {
|
|
showToast(t('inventory.assignFailed'), 'error');
|
|
return;
|
|
}
|
|
|
|
if (!settings?.disable_filament_warnings && trayInfo) {
|
|
const trayMaterial = trayInfo.material || trayInfo.type;
|
|
const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial);
|
|
const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament;
|
|
const trayProfile = trayInfo.profile || trayInfo.type;
|
|
const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
|
|
|
|
// Only material-bearing mismatches warn — profile-only deltas are
|
|
// silently resolved by the backend's AMS reconfigure on every assign
|
|
// (#1552).
|
|
if (materialMatchResult !== 'exact') {
|
|
let mismatchType: 'material' | 'partial' | 'material_profile' | 'partial_profile';
|
|
if (materialMatchResult === 'none' && !profileMatches) {
|
|
mismatchType = 'material_profile';
|
|
} else if (materialMatchResult === 'partial' && !profileMatches) {
|
|
mismatchType = 'partial_profile';
|
|
} else if (materialMatchResult === 'none') {
|
|
mismatchType = 'material';
|
|
} else {
|
|
mismatchType = 'partial';
|
|
}
|
|
|
|
setPendingAssignId(selectedSpoolId);
|
|
setMismatchDetails({
|
|
type: mismatchType,
|
|
spoolMaterial: selectedSpool.material || '',
|
|
trayMaterial: trayMaterial || '',
|
|
spoolProfile: spoolProfile || undefined,
|
|
trayProfile: trayProfile || undefined,
|
|
});
|
|
setShowMismatchConfirm(true);
|
|
return;
|
|
}
|
|
}
|
|
assignMutation.mutate(selectedSpoolId);
|
|
};
|
|
|
|
const handleConfirmMismatch = () => {
|
|
if (!pendingAssignId) return;
|
|
assignMutation.mutate(pendingAssignId);
|
|
setShowMismatchConfirm(false);
|
|
setPendingAssignId(null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center p-4 overflow-y-auto">
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
<div className="relative w-full max-w-2xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('inventory.assignSpool')}</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 space-y-4 overflow-y-auto">
|
|
{/* Tray info */}
|
|
{trayInfo && (
|
|
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
|
<p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
|
|
<div className="flex items-center gap-2">
|
|
{trayInfo.color && (
|
|
<span
|
|
className="w-4 h-4 rounded-full border border-black/20"
|
|
style={getSwatchStyle(trayInfo.color)}
|
|
/>
|
|
)}
|
|
<span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
|
|
<span className="text-bambu-gray">({trayInfo.location})</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search filter */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
<input
|
|
type="text"
|
|
value={searchFilter}
|
|
onChange={(e) => setSearchFilter(e.target.value)}
|
|
placeholder={t('inventory.searchSpools')}
|
|
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
|
|
/>
|
|
</div>
|
|
|
|
{/* Spool list */}
|
|
<div className="space-y-3">
|
|
{!spoolmanEnabled && (isLoading ? (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
|
</div>
|
|
) : filteredSpools && filteredSpools.length > 0 ? (
|
|
<div className="max-h-96 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{filteredSpools.map((spool: InventorySpool) => (
|
|
<button
|
|
key={spool.id}
|
|
onClick={() => { setSelectedSpoolId(spool.id); setSelectedSpoolmanSpoolId(null); }}
|
|
title={spool.note || undefined}
|
|
className={`p-2.5 rounded-lg border text-left transition-colors ${
|
|
selectedSpoolId === spool.id
|
|
? 'bg-bambu-green/20 border-bambu-green'
|
|
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
<p className="text-white text-sm font-medium truncate">
|
|
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
|
</p>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
{spool.rgba && (
|
|
<span
|
|
className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
|
|
style={getSwatchStyle(spool.rgba)}
|
|
/>
|
|
)}
|
|
<span className="text-xs text-bambu-gray truncate">
|
|
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
|
|
</span>
|
|
</div>
|
|
{spool.label_weight && (
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
|
|
</p>
|
|
)}
|
|
{spool.note && (
|
|
<p className="text-[10px] text-bambu-gray/70 mt-1 truncate" title={spool.note}>
|
|
{spool.note}
|
|
</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : availableSpools && availableSpools.length === 0 ? (
|
|
<div className="text-center py-8 text-bambu-gray">
|
|
<p>{t('inventory.noAvailableSpools')}</p>
|
|
{/* Diagnostic counter — when the picker is empty, having
|
|
the raw fetch / filter counts visible makes a
|
|
"spool I expected to see is missing" report
|
|
immediately answerable: if `total fetched` is 0 the
|
|
backend / cache returned nothing; if it's > 0 then
|
|
the archived / assigned-elsewhere filter ate the
|
|
spool and the toggle is the right escape hatch. */}
|
|
{spools && (
|
|
<p className="text-[10px] mt-2 opacity-60">
|
|
{spools.length} fetched · {spools.filter(s => s.archived_at).length} archived ·{' '}
|
|
{spools.filter(s => assignedSpoolIds.has(s.id)).length} assigned to other slots
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-bambu-gray">
|
|
<p>{t('inventory.noSpoolsMatch')}</p>
|
|
{availableSpools && (
|
|
<p className="text-[10px] mt-2 opacity-60">
|
|
{availableSpools.length} unassigned spools — {(availableSpools.length) - (filteredSpools?.length ?? 0)} filtered by tray match. Try "Show all spools".
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{spoolmanEnabled && (
|
|
<>
|
|
{spoolmanLoading ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
|
|
</div>
|
|
) : spoolmanSpools && spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)).length > 0 ? (
|
|
<>
|
|
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide pt-1">
|
|
{t('inventory.spoolmanSpools')}
|
|
</p>
|
|
<div className="max-h-64 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{filterSpoolsByQuery(spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)), searchFilter)
|
|
.map((spool: InventorySpool) => (
|
|
<button
|
|
key={`spoolman-${spool.id}`}
|
|
onClick={() => {
|
|
setSelectedSpoolmanSpoolId(spool.id);
|
|
setSelectedSpoolId(null);
|
|
}}
|
|
title={spool.note || undefined}
|
|
className={`p-2.5 rounded-lg border text-left transition-colors ${
|
|
selectedSpoolmanSpoolId === spool.id
|
|
? 'bg-bambu-green/20 border-bambu-green'
|
|
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
|
}`}
|
|
>
|
|
<p className="text-white text-sm font-medium truncate">
|
|
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
|
|
</p>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
{spool.rgba && (
|
|
<span
|
|
className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
|
|
style={getSwatchStyle(spool.rgba)}
|
|
/>
|
|
)}
|
|
<span className="text-xs text-bambu-gray truncate">
|
|
{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
|
|
</span>
|
|
</div>
|
|
{spool.label_weight && (
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
|
|
</p>
|
|
)}
|
|
{spool.note && (
|
|
<p className="text-[10px] text-bambu-gray/70 mt-1 truncate" title={spool.note}>
|
|
{spool.note}
|
|
</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer with filtering toggle */}
|
|
<div className="flex justify-between items-center p-4 border-t border-bambu-dark-tertiary">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="disable-filtering-toggle"
|
|
type="checkbox"
|
|
checked={disableFiltering}
|
|
onChange={() => setDisableFiltering(v => !v)}
|
|
className="accent-bambu-green w-4 h-4 rounded focus:ring-0 border-bambu-dark-tertiary"
|
|
/>
|
|
<label htmlFor="disable-filtering-toggle" className="text-xs text-bambu-gray select-none cursor-pointer">
|
|
{t('inventory.showAllSpools')}
|
|
</label>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="secondary" onClick={onClose}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleAssign}
|
|
disabled={(!selectedSpoolId && selectedSpoolmanSpoolId === null) || assignMutation.isPending || assignSpoolmanMutation.isPending}
|
|
>
|
|
{(assignMutation.isPending || assignSpoolmanMutation.isPending) ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t('inventory.assigning')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Package className="w-4 h-4" />
|
|
{t('inventory.assignSpool')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{assignMutation.isError && (
|
|
<div className="mx-4 mb-4 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
|
|
{(assignMutation.error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => {
|
|
let message = '';
|
|
|
|
if (mismatchDetails.type === 'material') {
|
|
message = t('inventory.assignMismatchMessage', {
|
|
spoolMaterial: mismatchDetails.spoolMaterial,
|
|
trayMaterial: mismatchDetails.trayMaterial,
|
|
location: trayInfo.location,
|
|
});
|
|
} else if (mismatchDetails.type === 'partial') {
|
|
message = t('inventory.assignPartialMismatchMessage', {
|
|
spoolMaterial: mismatchDetails.spoolMaterial,
|
|
trayMaterial: mismatchDetails.trayMaterial,
|
|
location: trayInfo.location,
|
|
});
|
|
} else if (mismatchDetails.type === 'material_profile') {
|
|
message = `${t('inventory.assignMismatchMessage', {
|
|
spoolMaterial: mismatchDetails.spoolMaterial,
|
|
trayMaterial: mismatchDetails.trayMaterial,
|
|
location: trayInfo.location,
|
|
})}\n\n${t('inventory.assignProfileMismatchMessage', {
|
|
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
|
|
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
|
|
location: trayInfo.location,
|
|
})}`;
|
|
} else if (mismatchDetails.type === 'partial_profile') {
|
|
message = `${t('inventory.assignPartialMismatchMessage', {
|
|
spoolMaterial: mismatchDetails.spoolMaterial,
|
|
trayMaterial: mismatchDetails.trayMaterial,
|
|
location: trayInfo.location,
|
|
})}\n\n${t('inventory.assignProfileMismatchMessage', {
|
|
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
|
|
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
|
|
location: trayInfo.location,
|
|
})}`;
|
|
}
|
|
|
|
// Always tell the user the AMS slot is going to be reconfigured —
|
|
// the existing wording made "Assign Anyway" sound like the popup was
|
|
// a no-op confirmation, when the backend in fact pushes the spool's
|
|
// profile to the slot on every assign (#1552).
|
|
message = `${message}\n\n${t('inventory.assignReconfigureNote')}`;
|
|
|
|
return (
|
|
<ConfirmModal
|
|
title={t('inventory.assignMismatchTitle')}
|
|
message={message}
|
|
confirmText={t('inventory.assignMismatchConfirm')}
|
|
variant="warning"
|
|
// Sit above the AssignSpoolModal wrapper (z-[100], #1336) —
|
|
// without this the mismatch dialog is hidden behind its parent.
|
|
overlayZIndex="z-[110]"
|
|
isLoading={assignMutation.isPending}
|
|
onConfirm={handleConfirmMismatch}
|
|
onCancel={() => {
|
|
if (!assignMutation.isPending) {
|
|
setShowMismatchConfirm(false);
|
|
setPendingAssignId(null);
|
|
setMismatchDetails(null);
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
})()}
|
|
</>
|
|
);
|
|
}
|