Просмотр исходного кода

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
maziggy 4 месяцев назад
Родитель
Сommit
9de0c0e7da

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 5 - 0
frontend/src/components/ConfirmModal.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -142,6 +142,7 @@ export default {
       problem: 'Problem',
       error: 'Fehler',
       finished: 'Fertig',
+      failed: 'Fehlgeschlagen',
       unknown: 'Unbekannt',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -142,6 +142,7 @@ export default {
       problem: 'Problem',
       error: 'Error',
       finished: 'Finished',
+      failed: 'Failed',
       unknown: 'Unknown',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -142,6 +142,7 @@ export default {
       problem: 'Problème',
       error: 'Erreur',
       finished: 'Terminé',
+      failed: 'Échec',
       unknown: 'Inconnu',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -142,6 +142,7 @@ export default {
       problem: 'Problema',
       error: 'Errore',
       finished: 'Finita',
+      failed: 'Fallita',
       unknown: 'Sconosciuto',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -141,6 +141,7 @@ export default {
       problem: '問題',
       error: 'エラー',
       finished: '完了',
+      failed: '失敗',
       unknown: '不明',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -142,6 +142,7 @@ export default {
       problem: 'Problema',
       error: 'Erro',
       finished: 'Concluído',
+      failed: 'Falhou',
       unknown: 'Desconhecido',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -142,6 +142,7 @@ export default {
       problem: '故障',
       error: '错误',
       finished: '已完成',
+      failed: '失败',
       unknown: '未知',
     },
     temperatures: {

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -142,6 +142,7 @@ export default {
       problem: '故障',
       error: '錯誤',
       finished: '已完成',
+      failed: '失敗',
       unknown: '未知',
     },
     temperatures: {

+ 96 - 163
frontend/src/pages/PrintersPage.tsx

@@ -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>
-                </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>
+          <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>
-              </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 - 0
frontend/src/utils/amsHelpers.ts

@@ -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,
+  };
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DI8Znu9h.js


+ 1 - 1
static/index.html

@@ -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>

Некоторые файлы не были показаны из-за большого количества измененных файлов