mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: 'Problem',
|
||||
error: 'Fehler',
|
||||
finished: 'Fertig',
|
||||
failed: 'Fehlgeschlagen',
|
||||
unknown: 'Unbekannt',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: 'Problem',
|
||||
error: 'Error',
|
||||
finished: 'Finished',
|
||||
failed: 'Failed',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: 'Problème',
|
||||
error: 'Erreur',
|
||||
finished: 'Terminé',
|
||||
failed: 'Échec',
|
||||
unknown: 'Inconnu',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: 'Problema',
|
||||
error: 'Errore',
|
||||
finished: 'Finita',
|
||||
failed: 'Fallita',
|
||||
unknown: 'Sconosciuto',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -141,6 +141,7 @@ export default {
|
||||
problem: '問題',
|
||||
error: 'エラー',
|
||||
finished: '完了',
|
||||
failed: '失敗',
|
||||
unknown: '不明',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: 'Problema',
|
||||
error: 'Erro',
|
||||
finished: 'Concluído',
|
||||
failed: 'Falhou',
|
||||
unknown: 'Desconhecido',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: '故障',
|
||||
error: '错误',
|
||||
finished: '已完成',
|
||||
failed: '失败',
|
||||
unknown: '未知',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
problem: '故障',
|
||||
error: '錯誤',
|
||||
finished: '已完成',
|
||||
failed: '失敗',
|
||||
unknown: '未知',
|
||||
},
|
||||
temperatures: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user