refactor(printer-card): in-file cleanup ahead of customization work (PR 1a of 3)

Three independent cleanups inside PrintersPage.tsx noticed during the
  architectural audit for the upcoming printer-card customization feature
  (modular widgets + tile-layout dashboard). All three are pure refactor -
  no behavior change, no UI change.

  1. getStatusDisplay i18n fix
     The function returned hardcoded English regardless of locale -
     "Printing" / "Paused" / "Finished" / "Failed" / "Idle". A latent bug
     that surfaced as German/French/etc. printer cards rendering English
     status text. Now takes `t` as its first arg and returns
     t('printers.status.X') keys. printers.status.{idle,printing,paused,
     finished} already existed in all 8 locales; only `failed` was missing
     - added natively across en/de/fr/it/ja/pt-BR/zh-CN/zh-TW.

  2. resolveSlotFill utility extraction
     The ~80-line "Spoolman tag -> slot assignment -> inventory -> AMS
     remain" fill-level chain was triply duplicated - once for regular AMS
     slots, once for HT AMS slots, once for external spool, with `*` /
     `ht*` / `ext*` variable prefixes. Pulled into
     utils/amsHelpers.ts::resolveSlotFill() as a single typed helper. Call
     sites now destructure { effectiveFill, fillSource, slotSpoolForFill,
     linkedSpool, slotAssignmentForFill } - last two are needed for
     downstream FilamentHoverCard link/unlink/assign wiring. Saves 43
     lines net (7,441 -> 7,398).

  3. Delete-confirm aligned with the rest
     The inline delete confirmation used <div className="fixed inset-0
     ..."> directly inside <CardContent> - the only confirmation in the
     file that wasn't using the shared ConfirmModal. Migrated to
     ConfirmModal with the "also delete archives" checkbox passed via a
     new optional `children` prop slot (rendered between message and
     buttons). The new prop generalizes ConfirmModal for any future
     confirm that needs a checkbox or extra inline form.

  Verification: npm run build clean; PrintersPageFillLevel.test.ts
  (10 tests) and PrinterQueueWidget.test.tsx (7 tests) pass.

  PR 1b (sub-component file moves + 2 modal extractions) and PR 1c (the
  4,055-line PrinterCard extraction itself) are deferred to dedicated
  sessions because moving that much inline JSX is too risky to bundle
  with anything else.#	modified:   frontend/src/utils/amsHelpers.ts
This commit is contained in:
maziggy
2026-05-09 13:46:10 +02:00
parent 77bf53b7f1
commit 9de0c0e7da
14 changed files with 665 additions and 597 deletions
+3
View File
@@ -9,6 +9,9 @@ All notable changes to Bambuddy will be documented in this file.
- **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head).
### Changed
- **Printer-card refactor (PR 1a of 3): in-file cleanup ahead of the printer-card customization work** — Three independent cleanups inside `frontend/src/pages/PrintersPage.tsx` that were noticed during the architectural audit for the upcoming printer-card customization feature (modular widgets + tile-layout dashboard). All three are pure refactor — no behavior change, no UI change. The two follow-up PRs (sub-component extractions + the big `PrinterCard` extraction itself) are deferred to dedicated sessions because moving 4,055 lines of inline JSX out of a 7,441-line file is too risky to bundle with anything else. **(1) `getStatusDisplay` i18n fix.** The function (line 1127) returned hardcoded English strings ("Printing", "Paused", "Finished", "Failed", "Idle") regardless of the user's selected language — a latent bug that surfaced as German/French/etc. printer cards rendering English status text. Now takes `t` as its first arg and returns `t('printers.status.X')` keys. The `printers.status.{idle,printing,paused,finished}` keys already existed in all 8 locales; only `failed` was missing — added natively across en/de/fr/it/ja/pt-BR/zh-CN/zh-TW. **(2) `resolveSlotFill` utility extraction.** The ~80-line "Spoolman tag → slot assignment → inventory → AMS-remain" fill-level resolution chain was triply duplicated in PrintersPage.tsx — once for regular AMS slots (`*` variables, ~3527–3556), once for HT AMS slots (`ht*` variables, ~3862–3887), once for external spool (`ext*` variables, ~4282–4309). Pulled into `frontend/src/utils/amsHelpers.ts::resolveSlotFill()` as a single typed helper; the three call sites now destructure `{ effectiveFill, fillSource, slotSpoolForFill, linkedSpool, slotAssignmentForFill }` from it. Last two return fields are needed for downstream `FilamentHoverCard` link/unlink/assign wiring (the `linkedSpoolId` lookup, the `onLinkSpool` short-circuit when an assignment already exists, and the `onUnlinkSpool` callback). PrintersPage.tsx shrinks 43 lines from this dedup alone (7,441 → 7,398). **(3) Delete-confirm aligned with the rest.** The inline delete confirmation at line 2729 used `<div className="fixed inset-0 ...">` directly inside `<CardContent>` — the only confirmation modal in the file that wasn't using the shared `ConfirmModal` component. Migrated to `ConfirmModal` with the "also delete archives" checkbox passed via a new optional `children` prop slot (rendered between message and buttons). The new prop generalizes `ConfirmModal` for any future confirm that needs a checkbox or extra inline form, without forcing every existing call site to change. **Verification:** `npm run build` clean; `PrintersPageFillLevel.test.ts` (10 tests, pins #676 inventory-vs-AMS preference logic) and `PrinterQueueWidget.test.tsx` (7 tests) all pass.
### Fixed
- **GCode Viewer had no in-app way to navigate back — the only exit was the browser's back button** — Opening the GCode Viewer from a File Manager card or an Archive card calls `navigate('/gcode-viewer?archive=…' | '?library_file=…')`, which mounts `GCodeViewerPage` as a full-height iframe inside the Layout shell. The page rendered nothing but the iframe, so once the third-party viewer's UI took over the content area there was no in-app affordance to return to the originating list — only the browser's back button. Reported by @maziggy. **Fix:** added a thin back bar above the iframe in `frontend/src/pages/GCodeViewerPage.tsx` with an `ArrowLeft` icon button. The button label adapts to the entry point — `Back to Print Archives` when the URL carries `?archive=`, `Back to File Manager` when it carries `?library_file=`, generic `Back` otherwise (covers the rare deep-link / shared-URL case). Click prefers `navigate(-1)` so the user lands back in their original list with scroll position and filters preserved; falls back to `/archives` or `/files` when the page was opened in a fresh tab and there's no SPA history to return to. Iframe height is now `flex: 1` inside a flex column under the bar instead of a hard-coded `calc(100vh - 3.5rem)` — the layout's existing fixed-header offset is unchanged, only the back bar (~36 px) is subtracted from the viewer's vertical real estate. **i18n:** new `gcodeViewer.{back,backToArchives,backToFiles}` namespace added to all 8 locales (en + de fully translated, fr/it/ja/pt-BR/zh-CN/zh-TW translated to native using each locale's existing page-title vocabulary — `Druckarchiv`/`Dateimanager`, `Archives d'impression`/`Gestionnaire de fichiers`, `Archivi di stampa`/`Gestore file`, `印刷アーカイブ`/`ファイル管理`, `Arquivos de impressão`/`Gerenciador de arquivos`, `打印归档`/`文件管理器`, `列印歸檔`/`檔案管理器`).
- **Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows** ([#1249](https://github.com/maziggy/bambuddy/issues/1249)) — The action row on each archive card has six buttons: two labelled (Reprint + Schedule, or Slice when the file isn't sliced yet) plus four icon-only utilities (open in slicer, external link, globe, download, trash). The labelled buttons used `flex-1` to share whatever space remained after the four fixed-width icon buttons, with the label rendered as `<span className="hidden sm:inline truncate">...</span>` — i.e. visible at any viewport ≥ 640px, with `truncate` ellipsizing when there isn't room. **The Tailwind viewport breakpoint can't see the card width.** The page's grid grows column count alongside viewport (`md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`), so cards stay roughly 320–380 px wide across breakpoints and the leftover ~30 px in each labelled button isn't enough for "Reprint", which lands on screen as "Re..." — repro'd from a small browser window in the reporter's case. **Fix:** breakpoint bumped from `hidden sm:inline` → `hidden xl:inline` on all three labelled buttons (Reprint at line 1106, Schedule at line 1117, Slice at line 1153 of `frontend/src/pages/ArchivesPage.tsx`). Labels now appear only at viewport ≥ 1280px where the cards (3-4 columns of ~320 px) actually have headroom for them; on narrow windows the buttons render icon-only with their existing `title=` tooltip kept intact for hover and assistive-tech disclosure. Trade-off accepted: a wide-viewport-with-wide-sidebar setup that compresses the card to under ~320px will still see the truncation, but that's a corner case — the common "small browser window" path is fixed without restructuring the row.
+5
View File
@@ -16,6 +16,9 @@ interface ConfirmModalProps {
loadingText?: string;
onConfirm: () => void;
onCancel: () => void;
// Optional slot rendered between the message and the buttons — for confirms
// that need a checkbox or extra inline form (e.g. "also delete archives").
children?: React.ReactNode;
}
export function ConfirmModal({
@@ -30,6 +33,7 @@ export function ConfirmModal({
loadingText,
onConfirm,
onCancel,
children,
}: ConfirmModalProps) {
const { t } = useTranslation();
const resolvedConfirmText = confirmText ?? t('common.confirm');
@@ -80,6 +84,7 @@ export function ConfirmModal({
<p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
</div>
</div>
{children !== undefined && <div className="mt-4">{children}</div>}
<div className="flex gap-3 mt-6">
<Button
variant={cancelVariant ?? 'secondary'}
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: 'Problem',
error: 'Fehler',
finished: 'Fertig',
failed: 'Fehlgeschlagen',
unknown: 'Unbekannt',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: 'Problem',
error: 'Error',
finished: 'Finished',
failed: 'Failed',
unknown: 'Unknown',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: 'Problème',
error: 'Erreur',
finished: 'Terminé',
failed: 'Échec',
unknown: 'Inconnu',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: 'Problema',
error: 'Errore',
finished: 'Finita',
failed: 'Fallita',
unknown: 'Sconosciuto',
},
temperatures: {
+1
View File
@@ -141,6 +141,7 @@ export default {
problem: '問題',
error: 'エラー',
finished: '完了',
failed: '失敗',
unknown: '不明',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: 'Problema',
error: 'Erro',
finished: 'Concluído',
failed: 'Falhou',
unknown: 'Desconhecido',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: '故障',
error: '错误',
finished: '已完成',
failed: '失败',
unknown: '未知',
},
temperatures: {
+1
View File
@@ -142,6 +142,7 @@ export default {
problem: '故障',
error: '錯誤',
finished: '已完成',
failed: '失敗',
unknown: '未知',
},
temperatures: {
+96 -163
View File
@@ -87,7 +87,7 @@ import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModa
import { FileUploadModal } from '../components/FileUploadModal';
import { PrintModal } from '../components/PrintModal';
import { PrinterInfoModal } from '../components/PrinterInfoModal';
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool } from '../utils/amsHelpers';
import { getGlobalTrayId, getFillBarColor, getFallbackSpoolTag, isBambuLabSpool, resolveSlotFill } from '../utils/amsHelpers';
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
import { Collapsible } from '../components/Collapsible';
@@ -1124,26 +1124,29 @@ function classifyPrinterStatus(
* Uses stg_cur_name for detailed calibration/preparation stages,
* otherwise formats the gcode_state nicely.
*/
function getStatusDisplay(state: string | null | undefined, stg_cur_name: string | null | undefined): string {
function getStatusDisplay(
t: (key: string) => string,
state: string | null | undefined,
stg_cur_name: string | null | undefined,
): string {
// If we have a specific stage name (calibration, heating, etc.), use it
if (stg_cur_name) {
return stg_cur_name;
}
// Format the gcode_state nicely
switch (state) {
case 'RUNNING':
return 'Printing';
return t('printers.status.printing');
case 'PAUSE':
return 'Paused';
return t('printers.status.paused');
case 'FINISH':
return 'Finished';
return t('printers.status.finished');
case 'FAILED':
return 'Failed';
return t('printers.status.failed');
case 'IDLE':
return 'Idle';
return t('printers.status.idle');
default:
return state ? state.charAt(0) + state.slice(1).toLowerCase() : 'Idle';
return state ? state.charAt(0) + state.slice(1).toLowerCase() : t('printers.status.idle');
}
}
@@ -2727,64 +2730,40 @@ function PrinterCard({
{/* Delete Confirmation */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardContent>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 rounded-full bg-red-500/20">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">{t('printers.confirm.deleteTitle')}</h3>
<p className="text-sm text-bambu-gray mt-1">
{t('printers.confirm.deleteMessage', { name: printer.name })}
</p>
</div>
<ConfirmModal
title={t('printers.confirm.deleteTitle')}
message={t('printers.confirm.deleteMessage', { name: printer.name })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate({ deleteArchives });
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
onCancel={() => {
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
>
<div className="bg-bambu-dark rounded-lg p-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={deleteArchives}
onChange={(e) => setDeleteArchives(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<div>
<span className="text-sm text-white">{t('printers.deleteArchives')}</span>
<p className="text-xs text-bambu-gray mt-0.5">
{deleteArchives
? t('printers.confirm.deleteArchivesNote')
: t('printers.confirm.keepArchivesNote')}
</p>
</div>
<div className="bg-bambu-dark rounded-lg p-3 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={deleteArchives}
onChange={(e) => setDeleteArchives(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<div>
<span className="text-sm text-white">{t('printers.deleteArchives')}</span>
<p className="text-xs text-bambu-gray mt-0.5">
{deleteArchives
? t('printers.confirm.deleteArchivesNote')
: t('printers.confirm.keepArchivesNote')}
</p>
</div>
</label>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
>
{t('common.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
deleteMutation.mutate({ deleteArchives });
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
>
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
</label>
</div>
</ConfirmModal>
)}
{/* Status */}
@@ -2809,7 +2788,7 @@ function PrinterCard({
) : (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1 flex items-center gap-1.5">
<p className="min-w-0 truncate text-xs text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
<p className="min-w-0 truncate text-xs text-bambu-gray">{getStatusDisplay(t, status.state, status.stg_cur_name)}</p>
{plateStatusPill}
</div>
{showClearPlateButton && (
@@ -2874,7 +2853,7 @@ function PrinterCard({
{status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (
<>
<div className="mb-1 flex items-center gap-2">
<p className="text-sm text-bambu-gray">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
<p className="text-sm text-bambu-gray">{getStatusDisplay(t, status.state, status.stg_cur_name)}</p>
{plateStatusPill}
</div>
<p className="text-white text-sm mb-2 truncate">
@@ -2920,7 +2899,7 @@ function PrinterCard({
<p className="text-sm text-bambu-gray mb-1">{t('printers.sort.status')}</p>
<div className="mb-2 flex items-center gap-2">
<p className="text-white text-sm">
{getStatusDisplay(status.state, status.stg_cur_name)}
{getStatusDisplay(t, status.state, status.stg_cur_name)}
</p>
{plateStatusPill}
</div>
@@ -3512,7 +3491,7 @@ function PrinterCard({
// Find tray data for this slot (may be undefined if data incomplete)
// Use array index if available, as tray.id may not always be set
const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);
const hasFillLevel = tray?.tray_type && tray.remain >= 0;
const hasFillLevel = !!tray?.tray_type && tray.remain >= 0;
const isEmpty = !tray?.tray_type;
// Check if this is the currently loaded tray
// Global tray ID = ams.id * 4 + slot index (for standard AMS)
@@ -3524,36 +3503,21 @@ function PrinterCard({
const slotPreset = slotPresets?.[globalTrayId];
// Fill level fallback chain: Spoolman → Inventory → AMS remain
const trayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();
const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
// Slot-assigned-only spool fill (no tag link required)
const slotAssignmentForFill = spoolmanEnabled && !spoolmanLoading
? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === ams.id && a.tray_id === slotIdx)
: undefined;
const slotSpoolForFill = slotAssignmentForFill
? spoolmanSpools?.find(s => s.id === slotAssignmentForFill.spoolman_spool_id)
: undefined;
const slotSpoolFill = (slotSpoolForFill && (slotSpoolForFill.label_weight ?? 0) > 0)
? Math.round(Math.max(0, (slotSpoolForFill.label_weight ?? 0) - slotSpoolForFill.weight_used) / (slotSpoolForFill.label_weight ?? 1) * 100)
: null;
const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
const inventoryFill = (() => {
const sp = inventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
// If inventory says 0% but AMS reports positive remain, prefer AMS
// (inventory weight_used may be stale or over-counted — #676)
const resolvedInventoryFill = (inventoryFill === 0 && hasFillLevel && tray.remain > 0)
? null : inventoryFill;
const effectiveFill = spoolmanFill ?? slotSpoolFill ?? resolvedInventoryFill ?? (hasFillLevel ? tray.remain : null);
const fillSource = (spoolmanFill !== null || slotSpoolFill !== null) ? 'spoolman' as const
: resolvedInventoryFill !== null ? 'inventory' as const
: hasFillLevel ? 'ams' as const
: undefined;
const { effectiveFill, fillSource, slotSpoolForFill, linkedSpool, slotAssignmentForFill } = resolveSlotFill({
tray,
printerSerial: printer.serial_number,
printerId: printer.id,
amsId: ams.id,
slotIdx,
hasFillLevel,
linkedSpools,
spoolmanEnabled,
spoolmanLoading,
spoolmanSlotAssignments,
spoolmanSpools,
inventoryAssignment,
});
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
@@ -3702,8 +3666,7 @@ function PrinterCard({
data={filamentData}
spoolman={{
enabled: spoolmanEnabled,
linkedSpoolId: (trayTag ? linkedSpools?.[trayTag]?.id : undefined)
?? slotAssignmentForFill?.spoolman_spool_id,
linkedSpoolId: linkedSpool?.id ?? slotAssignmentForFill?.spoolman_spool_id,
spoolmanUrl,
syncMode: spoolmanSyncMode,
// Suppress Link button when slot is already occupied by ANY assignment
@@ -3844,7 +3807,7 @@ function PrinterCard({
const isLeftNozzle = extruderId === 1;
const isRightNozzle = extruderId === 0;
const tray = ams.tray[0];
const hasFillLevel = tray?.tray_type && tray.remain >= 0;
const hasFillLevel = !!tray?.tray_type && tray.remain >= 0;
const isEmpty = !tray?.tray_type;
// Check if this is the currently loaded tray
const globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
@@ -3856,35 +3819,21 @@ function PrinterCard({
const htSlotId = tray?.id ?? 0;
// Fill level fallback chain: Spoolman → Inventory → AMS remain
const htTrayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId))?.toUpperCase();
const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
const htInventoryFill = (() => {
const sp = htInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
const htResolvedInventoryFill = (htInventoryFill === 0 && hasFillLevel && tray.remain > 0)
? null : htInventoryFill;
// Slot-assigned-only fill (when spool has no NFC tag but is slot-assigned)
const htSlotAssignmentForFill = spoolmanEnabled && !spoolmanLoading
? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === ams.id && a.tray_id === htSlotId)
: undefined;
const htSlotSpoolForFill = htSlotAssignmentForFill
? spoolmanSpools?.find(s => s.id === htSlotAssignmentForFill.spoolman_spool_id)
: undefined;
const htSlotSpoolFill = (htSlotSpoolForFill && (htSlotSpoolForFill.label_weight ?? 0) > 0)
? Math.round(Math.max(0, (htSlotSpoolForFill.label_weight ?? 0) - htSlotSpoolForFill.weight_used) / (htSlotSpoolForFill.label_weight ?? 1) * 100)
: null;
const htEffectiveFill = htSpoolmanFill ?? htSlotSpoolFill ?? htResolvedInventoryFill ?? (hasFillLevel ? tray.remain : null);
const htFillSource = (htSpoolmanFill !== null || htSlotSpoolFill !== null) ? 'spoolman' as const
: htResolvedInventoryFill !== null ? 'inventory' as const
: hasFillLevel ? 'ams' as const
: undefined;
const { effectiveFill: htEffectiveFill, fillSource: htFillSource, slotSpoolForFill: htSlotSpoolForFill, linkedSpool: htLinkedSpool, slotAssignmentForFill: htSlotAssignmentForFill } = resolveSlotFill({
tray,
printerSerial: printer.serial_number,
printerId: printer.id,
amsId: ams.id,
slotIdx: htSlotId,
hasFillLevel,
linkedSpools,
spoolmanEnabled,
spoolmanLoading,
spoolmanSlotAssignments,
spoolmanSpools,
inventoryAssignment: htInventoryAssignment,
});
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
@@ -4100,8 +4049,7 @@ function PrinterCard({
data={filamentData}
spoolman={{
enabled: spoolmanEnabled,
linkedSpoolId: (htTrayTag ? linkedSpools?.[htTrayTag]?.id : undefined)
?? htSlotAssignmentForFill?.spoolman_spool_id,
linkedSpoolId: htLinkedSpool?.id ?? htSlotAssignmentForFill?.spoolman_spool_id,
spoolmanUrl,
syncMode: spoolmanSyncMode,
// Suppress Link button when slot is occupied by ANY assignment (Phase 13 P13-6d)
@@ -4277,36 +4225,22 @@ function PrinterCard({
const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
const extInventoryFill = (() => {
const sp = extInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const extHasFillLevel = extTray.tray_type && extTray.remain >= 0;
// If inventory says 0% but AMS reports positive remain, prefer AMS (#676)
const extResolvedInventoryFill = (extInventoryFill === 0 && extHasFillLevel && extTray.remain > 0)
? null : extInventoryFill;
// Slot-assigned-only fill (when spool has no NFC tag but is slot-assigned)
const extSlotAssignmentForFill = spoolmanEnabled && !spoolmanLoading
? spoolmanSlotAssignments?.find(a => a.printer_id === printer.id && a.ams_id === 255 && a.tray_id === slotTrayId)
: undefined;
const extSlotSpoolForFill = extSlotAssignmentForFill
? spoolmanSpools?.find(s => s.id === extSlotAssignmentForFill.spoolman_spool_id)
: undefined;
const extSlotSpoolFill = (extSlotSpoolForFill && (extSlotSpoolForFill.label_weight ?? 0) > 0)
? Math.round(Math.max(0, (extSlotSpoolForFill.label_weight ?? 0) - extSlotSpoolForFill.weight_used) / (extSlotSpoolForFill.label_weight ?? 1) * 100)
: null;
const extEffectiveFill = extSpoolmanFill ?? extSlotSpoolFill ?? extResolvedInventoryFill ?? (extHasFillLevel ? extTray.remain : null);
const extFillSource = (extSpoolmanFill !== null || extSlotSpoolFill !== null) ? 'spoolman' as const
: extResolvedInventoryFill !== null ? 'inventory' as const
: extHasFillLevel ? 'ams' as const
: undefined;
const extHasFillLevel = !!extTray.tray_type && extTray.remain >= 0;
const { effectiveFill: extEffectiveFill, fillSource: extFillSource, slotSpoolForFill: extSlotSpoolForFill, linkedSpool: extLinkedSpool, slotAssignmentForFill: extSlotAssignmentForFill } = resolveSlotFill({
tray: extTray,
printerSerial: printer.serial_number,
printerId: printer.id,
amsId: 255,
slotIdx: slotTrayId,
hasFillLevel: extHasFillLevel,
linkedSpools,
spoolmanEnabled,
spoolmanLoading,
spoolmanSlotAssignments,
spoolmanSpools,
inventoryAssignment: extInventoryAssignment,
});
const extFilamentData = {
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
@@ -4412,8 +4346,7 @@ function PrinterCard({
data={extFilamentData}
spoolman={{
enabled: spoolmanEnabled,
linkedSpoolId: (extTrayTag ? linkedSpools?.[extTrayTag]?.id : undefined)
?? extSlotAssignmentForFill?.spoolman_spool_id,
linkedSpoolId: extLinkedSpool?.id ?? extSlotAssignmentForFill?.spoolman_spool_id,
spoolmanUrl,
syncMode: spoolmanSyncMode,
// Suppress Link button when slot is occupied by ANY assignment (Phase 13 P13-6d)
+119
View File
@@ -3,6 +3,7 @@
* These functions handle color normalization, slot labeling, and tray ID calculations
* for AMS, AMS-HT, and external spool configurations.
*/
import type { InventorySpool, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
import { parseUTCDate } from './date';
/**
@@ -270,3 +271,121 @@ export function isBambuLabSpool(tray: {
if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
return false;
}
/**
* Resolve a slot's effective fill level by walking the
* Spoolman → Inventory → AMS-remain fallback chain.
*
* Priority order:
* 1. Spoolman: spool linked via NFC tag (`linkedSpools[trayTag]`)
* 2. Spoolman: spool slot-assigned without an NFC tag
* (`spoolmanSlotAssignments` → `spoolmanSpools`)
* 3. Bambuddy inventory: spool slot-assigned via the inventory API
* 4. AMS firmware-reported `tray.remain`
*
* Issue #676: if inventory says 0% but AMS reports positive remain, prefer
* AMS — the inventory `weight_used` may be stale or over-counted.
*
* Used identically by regular AMS slots, AMS-HT slots, and external-spool
* slots; before extraction this logic was duplicated three times in
* PrintersPage.tsx with `*`, `ht*`, and `ext*` variable prefixes.
*/
export interface SlotFillContext {
tray: { tray_uuid?: string | null; tag_uid?: string | null; remain?: number } | null | undefined;
printerSerial: string;
printerId: number;
amsId: number;
slotIdx: number;
hasFillLevel: boolean;
linkedSpools: Record<string, LinkedSpoolInfo> | undefined;
spoolmanEnabled: boolean;
spoolmanLoading: boolean;
spoolmanSlotAssignments:
| { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number }[]
| undefined;
spoolmanSpools: InventorySpool[] | undefined;
inventoryAssignment: SpoolAssignment | null | undefined;
}
export interface SlotFillResult {
effectiveFill: number | null;
fillSource: 'spoolman' | 'inventory' | 'ams' | undefined;
slotSpoolForFill: InventorySpool | undefined;
// Also returned for downstream use (FilamentHoverCard link/unlink wiring):
linkedSpool: LinkedSpoolInfo | undefined;
slotAssignmentForFill:
| { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number }
| undefined;
}
export function resolveSlotFill(ctx: SlotFillContext): SlotFillResult {
const trayTag = (
ctx.tray?.tray_uuid ||
ctx.tray?.tag_uid ||
getFallbackSpoolTag(ctx.printerSerial, ctx.amsId, ctx.slotIdx)
)?.toUpperCase();
const linkedSpool = trayTag ? ctx.linkedSpools?.[trayTag] : undefined;
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
const slotAssignmentForFill =
ctx.spoolmanEnabled && !ctx.spoolmanLoading
? ctx.spoolmanSlotAssignments?.find(
(a) =>
a.printer_id === ctx.printerId &&
a.ams_id === ctx.amsId &&
a.tray_id === ctx.slotIdx,
)
: undefined;
const slotSpoolForFill = slotAssignmentForFill
? ctx.spoolmanSpools?.find((s) => s.id === slotAssignmentForFill.spoolman_spool_id)
: undefined;
const slotSpoolFill =
slotSpoolForFill && (slotSpoolForFill.label_weight ?? 0) > 0
? Math.round(
(Math.max(
0,
(slotSpoolForFill.label_weight ?? 0) - slotSpoolForFill.weight_used,
) /
(slotSpoolForFill.label_weight ?? 1)) *
100,
)
: null;
const inventoryFill = (() => {
const sp = ctx.inventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(
(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight) * 100,
);
}
return null;
})();
const trayRemain = ctx.tray?.remain ?? -1;
// #676: inventory 0% + AMS reports positive remain → prefer AMS.
const resolvedInventoryFill =
inventoryFill === 0 && ctx.hasFillLevel && trayRemain > 0 ? null : inventoryFill;
const effectiveFill =
spoolmanFill ??
slotSpoolFill ??
resolvedInventoryFill ??
(ctx.hasFillLevel ? trayRemain : null);
const fillSource =
spoolmanFill !== null || slotSpoolFill !== null
? ('spoolman' as const)
: resolvedInventoryFill !== null
? ('inventory' as const)
: ctx.hasFillLevel
? ('ams' as const)
: undefined;
return {
effectiveFill,
fillSource,
slotSpoolForFill,
linkedSpool,
slotAssignmentForFill,
};
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CqLSsmpD.js"></script>
<script type="module" crossorigin src="/assets/index-DI8Znu9h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSBzgKvT.css">
</head>
<body>