mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
Add spool inventory: AMS slot assignment, usage tracking, and remaining weight editing
Built-in spool inventory with AMS slot assignment for non-BL spools, automatic filament usage tracking via 3MF estimates, and remaining weight editing in the spool form. Inventory features: - AMS slot assignment: assign/unassign manual spools to AMS tray slots - Filter out Bambu Lab spools (RFID-managed) from assignment modal - Filter out already-assigned spools (one spool per slot) - Auto-unlink manual assignments when a BL spool is inserted - Hide assign/unassign UI on BL-occupied slots - Case-insensitive fingerprint matching for auto-unlink logic Usage tracking: - 3MF-based filament consumption tracking for non-BL spools - Extract per-filament used_g from archived 3MF slice_info - Map 3MF slot_id to AMS (ams_id, tray_id) positions - Scale estimates by print progress for failed/aborted prints - Skip BL spools (tracked via AMS remain% delta, Path 1) - Prevent double-counting with handled_trays set Other: - Add remaining weight field to spool edit form - Fix hardcoded "Empty" string in AssignSpoolModal (now i18n) - Add backend unit tests for usage_tracker (11 tests) - Add frontend tests for AssignSpoolModal filters (5 tests) - Update CHANGELOG, README, wiki docs, and website
This commit is contained in:
@@ -139,6 +139,28 @@ export function AdditionalSection({
|
||||
onChange={(weight) => updateField('core_weight', weight)}
|
||||
/>
|
||||
|
||||
{/* Current Weight (remaining filament) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={Math.max(0, formData.label_weight - formData.weight_used)}
|
||||
min={0}
|
||||
max={formData.label_weight}
|
||||
onChange={(e) => {
|
||||
const remaining = parseInt(e.target.value) || 0;
|
||||
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
|
||||
}}
|
||||
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
|
||||
</div>
|
||||
<span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ export function ColorSection({
|
||||
updateField,
|
||||
recentColors,
|
||||
onColorUsed,
|
||||
catalogColors,
|
||||
}: ColorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showAllColors, setShowAllColors] = useState(false);
|
||||
@@ -28,8 +29,87 @@ export function ColorSection({
|
||||
onColorUsed({ name, hex });
|
||||
};
|
||||
|
||||
// Colors to show based on search/expand state
|
||||
const filteredColors = useMemo(() => {
|
||||
// Filter catalog colors by the selected brand + material + subtype
|
||||
// Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
|
||||
// Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
|
||||
const matchedCatalogColors = useMemo(() => {
|
||||
if (catalogColors.length === 0) return [];
|
||||
const brand = formData.brand?.trim();
|
||||
const material = formData.material?.toLowerCase().trim();
|
||||
const subtype = formData.subtype?.toLowerCase().trim();
|
||||
if (!brand && !material) return [];
|
||||
|
||||
// Split brand into words (>= 2 chars) for word-based matching
|
||||
const brandWords = brand
|
||||
? brand.toLowerCase().split(/[\s\-_]+/).filter(w => w.length >= 2)
|
||||
: [];
|
||||
|
||||
const brandMatches = (manufacturer: string) => {
|
||||
if (brandWords.length === 0) return true; // no brand filter
|
||||
const mfrLower = manufacturer.toLowerCase();
|
||||
// Any significant brand word found in manufacturer name
|
||||
return brandWords.some(w => mfrLower.includes(w));
|
||||
};
|
||||
|
||||
// Build the combined material+subtype string to match catalog entries
|
||||
const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
|
||||
|
||||
// First pass: try exact fullMaterial match (e.g. "PETG Basic")
|
||||
if (fullMaterial) {
|
||||
const exact = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
c.material?.toLowerCase() === fullMaterial,
|
||||
);
|
||||
if (exact.length > 0) {
|
||||
return exact.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
// Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
|
||||
const normalized = fullMaterial.replace(/\+$/, '');
|
||||
if (normalized !== fullMaterial) {
|
||||
const normMatch = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
c.material?.toLowerCase() === normalized,
|
||||
);
|
||||
if (normMatch.length > 0) {
|
||||
return normMatch.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: match base material prefix (e.g. "PETG" matches "PETG Basic", "PETG-HF")
|
||||
if (material) {
|
||||
const byMaterial = catalogColors.filter(c =>
|
||||
brandMatches(c.manufacturer) &&
|
||||
(!c.material || c.material.toLowerCase().startsWith(material)),
|
||||
);
|
||||
if (byMaterial.length > 0) {
|
||||
return byMaterial.map(c => ({
|
||||
name: c.color_name,
|
||||
hex: c.hex_color.replace('#', '').substring(0, 6),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [catalogColors, formData.brand, formData.material, formData.subtype]);
|
||||
|
||||
const hasCatalogMatch = matchedCatalogColors.length > 0;
|
||||
|
||||
// Search within matched catalog colors
|
||||
const filteredCatalogColors = useMemo(() => {
|
||||
if (!colorSearch) return matchedCatalogColors;
|
||||
const q = colorSearch.toLowerCase();
|
||||
return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
|
||||
}, [matchedCatalogColors, colorSearch]);
|
||||
|
||||
// Fallback hardcoded colors for search/expand
|
||||
const filteredFallbackColors = useMemo(() => {
|
||||
if (colorSearch) {
|
||||
return ALL_COLORS.filter(c =>
|
||||
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
|
||||
@@ -84,48 +164,81 @@ export function ColorSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Swatches Grid */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray">
|
||||
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
|
||||
{!colorSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllColors(!showAllColors)}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors"
|
||||
>
|
||||
{showAllColors ? (
|
||||
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
|
||||
) : (
|
||||
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Color Swatches */}
|
||||
{hasCatalogMatch ? (
|
||||
/* Catalog colors matching selected brand/material */
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredCatalogColors.map(color => (
|
||||
<button
|
||||
key={`${color.hex}-${color.name}`}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredCatalogColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredColors.map(color => (
|
||||
<button
|
||||
key={color.hex}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
) : (
|
||||
/* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray">
|
||||
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
|
||||
{!colorSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllColors(!showAllColors)}
|
||||
className="flex items-center gap-1 hover:text-white transition-colors"
|
||||
>
|
||||
{showAllColors ? (
|
||||
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
|
||||
) : (
|
||||
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredFallbackColors.map(color => (
|
||||
<button
|
||||
key={color.hex}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
|
||||
isSelected(color.hex)
|
||||
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
style={{ backgroundColor: `#${color.hex}` }}
|
||||
title={color.name}
|
||||
>
|
||||
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
|
||||
{color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredFallbackColors.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Color Input */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SpoolFormData {
|
||||
rgba: string;
|
||||
label_weight: number;
|
||||
core_weight: number;
|
||||
weight_used: number;
|
||||
slicer_filament: string;
|
||||
note: string;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export const defaultFormData: SpoolFormData = {
|
||||
rgba: '808080FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 0,
|
||||
slicer_filament: '',
|
||||
note: '',
|
||||
};
|
||||
@@ -79,6 +81,7 @@ export interface FilamentSectionProps extends SectionProps {
|
||||
export interface ColorSectionProps extends SectionProps {
|
||||
recentColors: ColorPreset[];
|
||||
onColorUsed: (color: ColorPreset) => void;
|
||||
catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
|
||||
}
|
||||
|
||||
// Additional section props
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SlicerSetting } from '../../api/client';
|
||||
import type { SlicerSetting, LocalPreset } from '../../api/client';
|
||||
import type { ColorPreset, FilamentOption } from './types';
|
||||
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
|
||||
|
||||
@@ -91,8 +91,8 @@ export function parsePresetName(name: string): { brand: string; material: string
|
||||
return { brand, material, variant };
|
||||
}
|
||||
|
||||
// Extract unique brands from cloud presets
|
||||
export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
|
||||
// Extract unique brands from cloud presets and local presets
|
||||
export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
|
||||
const brandSet = new Set<string>(DEFAULT_BRANDS);
|
||||
|
||||
for (const preset of presets) {
|
||||
@@ -102,14 +102,56 @@ export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract brands from local presets
|
||||
if (localPresets) {
|
||||
for (const preset of localPresets) {
|
||||
if (preset.filament_vendor && preset.filament_vendor.length > 1) {
|
||||
brandSet.add(preset.filament_vendor);
|
||||
} else {
|
||||
const { brand } = parsePresetName(preset.name);
|
||||
if (brand && brand.length > 1) {
|
||||
brandSet.add(brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Build filament options from cloud presets
|
||||
// Build filament options from local presets (OrcaSlicer imports)
|
||||
function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
|
||||
const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
|
||||
if (filamentPresets.length === 0) return [];
|
||||
|
||||
const presetsMap = new Map<string, FilamentOption>();
|
||||
for (const preset of filamentPresets) {
|
||||
const baseName = preset.name.replace(/@.*$/, '').trim();
|
||||
const existing = presetsMap.get(baseName);
|
||||
if (existing) {
|
||||
existing.allCodes.push(String(preset.id));
|
||||
} else {
|
||||
// Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
|
||||
const code = preset.filament_type || String(preset.id);
|
||||
presetsMap.set(baseName, {
|
||||
code,
|
||||
name: baseName,
|
||||
displayName: baseName,
|
||||
isCustom: false,
|
||||
allCodes: [code],
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
// Build filament options: cloud presets → local presets → hardcoded fallback
|
||||
export function buildFilamentOptions(
|
||||
cloudPresets: SlicerSetting[],
|
||||
configuredPrinterModels: Set<string>,
|
||||
localPresets?: LocalPreset[],
|
||||
): FilamentOption[] {
|
||||
// 1. Cloud presets (highest priority)
|
||||
if (cloudPresets.length > 0) {
|
||||
const customPresets: FilamentOption[] = [];
|
||||
const defaultPresetsMap = new Map<string, FilamentOption>();
|
||||
@@ -155,7 +197,13 @@ export function buildFilamentOptions(
|
||||
].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
// 2. Local presets (OrcaSlicer imports)
|
||||
if (localPresets && localPresets.length > 0) {
|
||||
const localOptions = buildLocalFilamentOptions(localPresets);
|
||||
if (localOptions.length > 0) return localOptions;
|
||||
}
|
||||
|
||||
// 3. Hardcoded fallback
|
||||
return FALLBACK_PRESETS;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user