);
}
interface KProfileModalProps {
profile?: KProfile;
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
initialNote?: string; // Initial note value for the profile
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
onClose: () => void;
onSave: () => void;
onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
hasPermission: (permission: Permission) => boolean;
}
function KProfileModal({
profile,
printerId,
nozzleDiameter,
existingProfiles = [],
builtinFilaments = [],
filamentPresets = [],
isDualNozzle = false,
supportsFlowType = true,
initialNote = '',
initialNoteKey = null,
onClose,
onSave,
onSaveNote,
hasPermission,
}: KProfileModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
profile?.k_value ? truncateK(profile.k_value) : '0.020'
);
// What the Filament select is bound to. When editing, the printer's own
// filament_id (the select is read-only). For a new profile, the *preset
// handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
// setting_id or a builtin filament id — which is resolved to a real
// filament_id on submit, since only some tiers carry one directly.
const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
// Both selects are read-only while editing: they report what the printer
// holds, they don't set it. '' means the printer reported no nozzle_id, which
// single-nozzle models never do (#1748) — showing "High Flow" there was the
// UI inventing a value the printer never sent.
const [nozzleType, setNozzleType] = useState(
profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
);
// For new profiles on dual-nozzle: allow selecting multiple extruders
// For editing: use single extruder from the profile
const [selectedExtruders, setSelectedExtruders] = useState(
profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles
);
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
const [note, setNote] = useState(initialNote);
const [filamentQuery, setFilamentQuery] = useState('');
// The modal defers its own close so the printer has time to process the
// command; that timer must not outlive the modal.
const { schedule: scheduleClose } = useCancellableTimeout();
// Name for the filament an existing profile is bound to. The builtin table
// (which the parent has already merged with the user's cloud presets) is
// authoritative; a profile whose filament_id is in neither falls back to the
// name the printer stored for it.
const editedFilamentName = React.useMemo(() => {
if (!profile?.filament_id) return '';
const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
if (builtinName) return builtinName;
const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
}, [profile, existingProfiles, builtinFilaments]);
// The tiered list, grouped for rendering. Order is fixed app-wide —
// imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
// and buildFilamentPresetOptions has already sorted by it, so grouping is
// just a partition that preserves that order.
const presetGroups = React.useMemo(() => {
const labels: [FilamentPresetSource, string][] = [
['local', t('kProfiles.modal.source.local')],
['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
['cloud', t('kProfiles.modal.source.bambuCloud')],
['builtin', t('kProfiles.modal.source.builtin')],
];
const query = filamentQuery.trim().toLowerCase();
const matches = query
? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
: filamentPresets;
return labels
.map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
.filter(g => g.items.length > 0);
}, [filamentPresets, filamentQuery, t]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
console.log('[KProfile] Calling API...');
return api.setKProfile(printerId, data);
},
onSuccess: (result, variables) => {
console.log('[KProfile] Save success:', result);
showToast(t('kProfiles.toast.profileSaved'));
// Save note if it changed (including clearing it)
if (onSaveNote && note !== initialNote) {
let profileKey: string;
if (note === '' && initialNoteKey) {
// Clearing note: use the same key it was stored under
profileKey = initialNoteKey;
} else if (profile && profile.slot_id > 0) {
// Editing: use setting_id if available, or composite key with slot_id
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
} else {
// New profile: use name as key (matched against the reloaded profile,
// so it has to be the resolved filament_id that was sent — not the
// preset handle the user picked).
profileKey = `name_${name}_${variables.filament_id}`;
}
onSaveNote(profileKey, note);
}
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before closing to give printer time to process the save
// onSave will trigger refetch in the parent component
scheduleClose(() => {
setIsSyncing(false);
onSave();
}, 2500);
},
onError: (error: Error) => {
console.error('[KProfile] Save error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
const deleteMutation = useMutation({
mutationFn: (data: KProfileDelete) => {
console.log('[KProfile] Deleting profile...');
return api.deleteKProfile(printerId, data);
},
onSuccess: (result) => {
console.log('[KProfile] Delete success:', result);
showToast(t('kProfiles.toast.profileDeleted'));
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add longer delay for delete - printer needs more time to process
// before it can return the updated profile list
scheduleClose(() => {
setIsSyncing(false);
onClose();
}, 4000);
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
showToast(error.message, 'error');
setIsSyncing(false);
},
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDelete = () => {
if (!profile) return;
deleteMutation.mutate({
slot_id: profile.slot_id,
extruder_id: profile.extruder_id,
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
setting_id: profile.setting_id,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate at least one extruder is selected for dual-nozzle
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
return;
}
// Format k_value to 6 decimal places for Bambu protocol
const formattedKValue = parseFloat(kValue).toFixed(6);
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// An edit is delete + re-add on single-nozzle printers, so the nozzle
// fields have to survive the round trip — both selects are disabled while
// editing. Rebuilding them blindly from the selects is what let a 0.6mm
// profile come back as "HH00-0.4" once the parse defaults had stamped it
// 0.4 (#1748), so prefer whatever the printer reported. Where it reported
// no nozzle_id at all, send the rebuilt one rather than an empty string —
// the field is part of the profile's identity on the wire and the slicer
// always populates it.
const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
// The printer indexes its calibration table by filament_id, so the preset
// the user picked has to be reduced to one before anything is sent. Only
// the builtin tier and Bambu's official cloud presets carry one outright;
// a cloud *user* preset needs its detail fetched, and imported / Orca
// presets have no Bambu id at all and map to the generic for their
// material. Refuse rather than guess when nothing resolves — a profile
// filed under the wrong filament is invisible to the slot that needs it.
let resolvedFilamentId = profile?.filament_id || '';
if (!profile) {
const picked = filamentPresets.find(p => p.id === filamentChoice);
if (!picked) {
showToast(t('kProfiles.toast.selectFilament'), 'error');
return;
}
resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
if (!resolvedFilamentId) {
showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
return;
}
}
// For editing or single extruder: just save one profile
if (profile || selectedExtruders.length === 1) {
const payload = {
name: name,
k_value: formattedKValue,
filament_id: resolvedFilamentId,
nozzle_id: editNozzleId,
nozzle_diameter: editDiameter,
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
};
console.log('[KProfile] Saving profile:', payload);
saveMutation.mutate(payload);
return;
}
// For new profiles with multiple extruders: use batch endpoint
setIsSyncing(true);
setSavingProgress({ current: 1, total: selectedExtruders.length });
// Build payload for all selected extruders
const batchPayload = selectedExtruders.map(extruderId => ({
name: name,
k_value: formattedKValue,
filament_id: resolvedFilamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
}));
console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${resolvedFilamentId}`;
onSaveNote(profileKey, note);
}
} catch (error) {
console.error('[KProfile] Failed to save batch:', error);
showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
}
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
// Wait for final sync before closing
// onSave will trigger refetch in the parent component
scheduleClose(() => {
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
}, 3000);
};
return (