mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'à',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2618,6 +2618,9 @@ export default {
|
||||
loadedInAms: 'AMS/Extに装填中',
|
||||
remaining: '残り',
|
||||
lowStockThreshold: '残り20%未満',
|
||||
weightCheck: '重量チェック',
|
||||
lastWeighed: '最終計量',
|
||||
neverWeighed: '未計量',
|
||||
search: 'スプールを検索...',
|
||||
showing: '表示',
|
||||
to: '〜',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user