Fix SpoolBuddy tag_type for linked spools + add inventory weight check column

SpoolBuddy's "Link to Spool" used the generic updateSpool API which only
  set tag_uid, leaving tag_type and data_origin empty. Now uses linkTagToSpool
  with tag_type='generic' and data_origin='nfc_link'.

  Added a "Weight Check" inventory column (hidden by default) that compares
  each spool's last scale measurement against calculated gross weight with
  ±50g tolerance. Shows green check for match, yellow warning + sync button
  for mismatch. Backend stores last_scale_weight and last_weighed_at on each
  spool when weight is synced via SpoolBuddy. Includes edge case handling
  when scale weight < core weight. i18n keys added for all 6 locales.
This commit is contained in:
maziggy
2026-03-02 08:29:22 +01:00
parent 6a4d890176
commit a5016432df
17 changed files with 202 additions and 79 deletions
+2
View File
@@ -1820,6 +1820,8 @@ export interface InventorySpool {
created_at: string;
updated_at: string;
cost_per_kg: number | null;
last_scale_weight: number | null;
last_weighed_at: string | null;
k_profiles?: SpoolKProfile[];
}
+3
View File
@@ -2692,6 +2692,9 @@ export default {
loadedInAms: 'Im AMS/Ext geladen',
remaining: 'Verbleibend',
lowStockThreshold: '<20% verbleibend',
weightCheck: 'Gewichtskontrolle',
lastWeighed: 'Zuletzt gewogen',
neverWeighed: 'Nie gewogen',
search: 'Spulen suchen...',
showing: 'Zeige',
to: 'bis',
+3
View File
@@ -2696,6 +2696,9 @@ export default {
loadedInAms: 'Loaded in AMS/Ext',
remaining: 'Remaining',
lowStockThreshold: '<20% remaining',
weightCheck: 'Weight Check',
lastWeighed: 'Last weighed',
neverWeighed: 'Never weighed',
search: 'Search spools...',
showing: 'Showing',
to: 'to',
+3
View File
@@ -2684,6 +2684,9 @@ export default {
loadedInAms: 'Chargé dans AMS/Ext',
remaining: 'Restant',
lowStockThreshold: '<20% restant',
weightCheck: 'Vérification poids',
lastWeighed: 'Dernière pesée',
neverWeighed: 'Jamais pesé',
search: 'Chercher bobines...',
showing: 'Affichage',
to: 'à',
+3
View File
@@ -2454,6 +2454,9 @@ export default {
spoolRestored: 'Bobina ripristinata',
deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
advancedSettings: 'Impostazioni Avanzate',
weightCheck: 'Controllo Peso',
lastWeighed: 'Ultima pesatura',
neverWeighed: 'Mai pesato',
},
// Timelapse
+3
View File
@@ -2618,6 +2618,9 @@ export default {
loadedInAms: 'AMS/Extに装填中',
remaining: '残り',
lowStockThreshold: '残り20%未満',
weightCheck: '重量チェック',
lastWeighed: '最終計量',
neverWeighed: '未計量',
search: 'スプールを検索...',
showing: '表示',
to: '〜',
+3
View File
@@ -2696,6 +2696,9 @@ export default {
loadedInAms: 'Carregado no AMS/Ext',
remaining: 'Restante',
lowStockThreshold: '<20% restante',
weightCheck: 'Verificação de Peso',
lastWeighed: 'Última pesagem',
neverWeighed: 'Nunca pesado',
search: 'Pesquisar carretéis...',
showing: 'Mostrando',
to: 'até',
+85 -7
View File
@@ -5,9 +5,9 @@ import {
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown,
ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
} from 'lucide-react';
import { api } from '../api/client';
import { api, spoolbuddyApi } from '../api/client';
import type { InventorySpool, SpoolAssignment } from '../api/client';
import { Button } from '../components/Button';
import { SpoolFormModal } from '../components/SpoolFormModal';
@@ -63,6 +63,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'stock', label: 'Stock', visible: false },
{ id: 'remaining', label: 'Remaining', visible: true },
{ id: 'cost_per_kg', label: 'Cost/kg', visible: false },
{ id: 'weight_check', label: 'Weight Check', visible: false },
];
function loadColumnConfig(): ColumnConfig[] {
@@ -126,6 +127,7 @@ type CellCtx = {
currencySymbol: string;
dateFormat: DateFormat;
t: TFn;
onSyncWeight?: (spool: InventorySpool) => void;
};
// Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -156,6 +158,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
stock: (t) => t('inventory.stock'),
remaining: (t) => t('inventory.remaining'),
cost_per_kg: (t) => t('inventory.costPerKg'),
weight_check: (t) => t('inventory.weightCheck'),
};
// Column cell renderers (25 columns — matching SpoolBuddy exactly)
@@ -285,6 +288,59 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
{spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
</span>
),
weight_check: ({ spool, onSyncWeight }) => {
const scaleWeight = spool.last_scale_weight;
if (scaleWeight == null) return <span className="text-sm text-bambu-gray/50" title="No scale measurement">-</span>;
const coreWeight = spool.core_weight || 0;
const calculatedWeight = Math.max(0, spool.label_weight - spool.weight_used) + coreWeight;
// Edge case: scale < core_weight means spool is empty or not on scale — treat as match
let difference: number;
let isMatch: boolean;
if (scaleWeight < coreWeight) {
difference = scaleWeight - coreWeight;
isMatch = true;
} else {
difference = scaleWeight - calculatedWeight;
isMatch = Math.abs(difference) <= 50;
}
const diffStr = difference > 0 ? `+${Math.round(difference)}` : `${Math.round(difference)}`;
const tooltip = isMatch
? `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (within tolerance)`
: `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (mismatch!)`;
return (
<div
className={`flex items-center gap-1 text-sm font-medium ${isMatch ? 'text-green-400' : 'text-yellow-400'}`}
title={tooltip}
>
<span>{Math.round(scaleWeight)}g</span>
{isMatch ? (
<Check className="w-3 h-3" />
) : (
<>
<AlertTriangle className="w-3 h-3" />
{onSyncWeight && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onSyncWeight(spool);
}}
className="p-1 hover:bg-bambu-green/20 rounded transition-colors text-bambu-green"
title="Sync: trust scale weight and reset tracking"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</>
)}
</div>
);
},
};
// Sort value extractors — return a comparable value for each sortable column
@@ -315,6 +371,11 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
tag_type: (s) => (s.tag_type || '').toLowerCase(),
stock: (s) => s.slicer_filament ? 1 : 0,
cost_per_kg: (s) => s.cost_per_kg ?? 0,
weight_check: (s) => {
if (s.last_scale_weight == null) return -1;
const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
return Math.abs(s.last_scale_weight - expectedGross);
},
};
const SORT_STATE_KEY = 'bambuddy-inventory-sort';
@@ -440,6 +501,18 @@ function InventoryPage() {
},
});
const handleSyncWeight = async (spool: InventorySpool) => {
if (spool.last_scale_weight == null) return;
try {
await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
showToast(`Synced "${spoolName}" to scale weight`, 'success');
} catch {
showToast('Failed to sync weight', 'error');
}
};
// Stats calculation (active spools only)
const stats = useMemo(() => {
if (!spools) return null;
@@ -1139,6 +1212,7 @@ function InventoryPage() {
currencySymbol={currencySymbol}
dateFormat={dateFormat}
t={t}
onSyncWeight={handleSyncWeight}
/>
);
}
@@ -1160,6 +1234,7 @@ function InventoryPage() {
currencySymbol={currencySymbol}
dateFormat={dateFormat}
t={t}
onSyncWeight={handleSyncWeight}
/>
);
})}
@@ -1436,7 +1511,7 @@ function SpoolCard({
/* Single spool row for table view */
function SpoolTableRow({
spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
}: {
spool: InventorySpool;
remaining: number;
@@ -1450,6 +1525,7 @@ function SpoolTableRow({
currencySymbol: string;
dateFormat: DateFormat;
t: TFn;
onSyncWeight?: (spool: InventorySpool) => void;
}) {
return (
<tr
@@ -1460,7 +1536,7 @@ function SpoolTableRow({
>
{visibleColumns.map((colId) => (
<td key={colId} className="py-3 px-4">
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
{columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
</td>
))}
<td className="py-3 px-4">
@@ -1490,7 +1566,7 @@ function SpoolTableRow({
function SpoolTableGroup({
spools, representative, remaining, pct, isExpanded, onToggle,
onEdit, onArchive, onDelete,
visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
}: {
spools: InventorySpool[];
representative: InventorySpool;
@@ -1506,6 +1582,7 @@ function SpoolTableGroup({
currencySymbol: string;
dateFormat: DateFormat;
t: TFn;
onSyncWeight?: (spool: InventorySpool) => void;
}) {
return (
<>
@@ -1519,14 +1596,14 @@ function SpoolTableGroup({
{idx === 0 ? (
<div className="flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
{columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
{columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
</div>
) : colId === 'id' ? (
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
{t('inventory.groupedSpools', { count: spools.length })}
</span>
) : (
columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })
columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })
)}
</td>
))}
@@ -1555,6 +1632,7 @@ function SpoolTableGroup({
currencySymbol={currencySymbol}
dateFormat={dateFormat}
t={t}
onSyncWeight={onSyncWeight}
/>
);
})}
@@ -3,7 +3,7 @@ import { useOutletContext, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
import { api, type InventorySpool } from '../../api/client';
import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
@@ -177,26 +177,6 @@ export function SpoolBuddyDashboard() {
}, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
// Auto-sync weight once when known spool first detected
const [weightUpdatedForSpool, setWeightUpdatedForSpool] = useState<number | null>(null);
useEffect(() => {
if (displayedSpool?.id !== weightUpdatedForSpool) {
setWeightUpdatedForSpool(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedSpool?.id]);
useEffect(() => {
if (displayedSpool && currentTagId && weightStable && weightUpdatedForSpool !== displayedSpool.id) {
setWeightUpdatedForSpool(displayedSpool.id);
const newWeight = currentWeight !== null ? Math.round(Math.max(0, currentWeight)) : null;
if (newWeight !== null) {
spoolbuddyApi.updateSpoolWeight(displayedSpool.id, newWeight)
.catch((err) => console.error('Failed to update spool weight:', err));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedSpool?.id, currentTagId, weightStable]);
const handleCloseSpoolCard = () => {
setHiddenTagId(displayedTagId);
@@ -205,7 +185,11 @@ export function SpoolBuddyDashboard() {
const handleLinkTagToSpool = async (spool: InventorySpool) => {
if (!displayedTagId) return;
try {
await api.updateSpool(spool.id, { tag_uid: displayedTagId });
await api.linkTagToSpool(spool.id, {
tag_uid: displayedTagId,
tag_type: 'generic',
data_origin: 'nfc_link',
});
setShowLinkModal(false);
refetchSpools();
} catch (e) {