|
@@ -1,6 +1,6 @@
|
|
|
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react';
|
|
import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react';
|
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
|
|
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
import {
|
|
import {
|
|
|
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
|
|
Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
import { ForecastPanel } from '../components/ForecastPanel';
|
|
import { ForecastPanel } from '../components/ForecastPanel';
|
|
|
import { api, spoolbuddyApi, ApiError } from '../api/client';
|
|
import { api, spoolbuddyApi, ApiError } from '../api/client';
|
|
|
-import type { InventorySpool, SpoolCatalogEntry } from '../api/client';
|
|
|
|
|
|
|
+import type { InventorySpool, SpoolCatalogEntry, LocationHASensorReading } from '../api/client';
|
|
|
import { Button } from '../components/Button';
|
|
import { Button } from '../components/Button';
|
|
|
import { FilamentSwatch } from '../components/FilamentSwatch';
|
|
import { FilamentSwatch } from '../components/FilamentSwatch';
|
|
|
|
|
+import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
|
|
|
import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
|
|
import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
|
|
|
import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
|
|
import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
|
|
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
@@ -34,6 +35,12 @@ import {
|
|
|
invalidateSpoolAndLocationQueries,
|
|
invalidateSpoolAndLocationQueries,
|
|
|
} from '../utils/inventoryQueries';
|
|
} from '../utils/inventoryQueries';
|
|
|
import { aggregateGroupSpool } from '../utils/inventoryGrouping';
|
|
import { aggregateGroupSpool } from '../utils/inventoryGrouping';
|
|
|
|
|
+import {
|
|
|
|
|
+ locationSensorReadingAlertStatus,
|
|
|
|
|
+ locationSensorValueColorClass,
|
|
|
|
|
+ useLocationSensorColorPrefs,
|
|
|
|
|
+ type LocationSensorAlertColor,
|
|
|
|
|
+} from '../utils/locationSensorDefaults';
|
|
|
|
|
|
|
|
type ArchiveFilter = 'active' | 'archived';
|
|
type ArchiveFilter = 'active' | 'archived';
|
|
|
type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
|
|
type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
|
|
@@ -79,6 +86,9 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
|
|
|
{ id: 'slicer_filament', label: 'Slicer Filament', visible: false },
|
|
{ id: 'slicer_filament', label: 'Slicer Filament', visible: false },
|
|
|
{ id: 'location', label: 'Location', visible: true },
|
|
{ id: 'location', label: 'Location', visible: true },
|
|
|
{ id: 'storage_location', label: 'Storage Location', visible: false },
|
|
{ id: 'storage_location', label: 'Storage Location', visible: false },
|
|
|
|
|
+ { id: 'temperature', label: 'Temperature', visible: false },
|
|
|
|
|
+ { id: 'humidity', label: 'Humidity', visible: false },
|
|
|
|
|
+ { id: 'battery', label: 'Battery', visible: false },
|
|
|
{ id: 'label_weight', label: 'Label', visible: true },
|
|
{ id: 'label_weight', label: 'Label', visible: true },
|
|
|
{ id: 'net', label: 'Net', visible: true },
|
|
{ id: 'net', label: 'Net', visible: true },
|
|
|
{ id: 'gross', label: 'Gross', visible: false },
|
|
{ id: 'gross', label: 'Gross', visible: false },
|
|
@@ -107,9 +117,21 @@ function loadColumnConfig(): ColumnConfig[] {
|
|
|
const storedIds = new Set(parsed.map((c) => c.id));
|
|
const storedIds = new Set(parsed.map((c) => c.id));
|
|
|
// Keep stored columns that still exist in defaults
|
|
// Keep stored columns that still exist in defaults
|
|
|
const validStored = parsed.filter((c) => defaultIds.has(c.id));
|
|
const validStored = parsed.filter((c) => defaultIds.has(c.id));
|
|
|
- // Add any new default columns not in stored config
|
|
|
|
|
- const newColumns = DEFAULT_COLUMNS.filter((c) => !storedIds.has(c.id));
|
|
|
|
|
- return [...validStored, ...newColumns];
|
|
|
|
|
|
|
+ const merged = [...validStored];
|
|
|
|
|
+ for (const col of DEFAULT_COLUMNS) {
|
|
|
|
|
+ if (storedIds.has(col.id)) continue;
|
|
|
|
|
+ const defaultIndex = DEFAULT_COLUMNS.indexOf(col);
|
|
|
|
|
+ let insertAt = merged.length;
|
|
|
|
|
+ for (let i = defaultIndex - 1; i >= 0; i--) {
|
|
|
|
|
+ const idx = merged.findIndex((c) => c.id === DEFAULT_COLUMNS[i].id);
|
|
|
|
|
+ if (idx !== -1) {
|
|
|
|
|
+ insertAt = idx + 1;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ merged.splice(insertAt, 0, col);
|
|
|
|
|
+ }
|
|
|
|
|
+ return merged;
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
// Ignore errors
|
|
// Ignore errors
|
|
@@ -168,10 +190,15 @@ type CellCtx = {
|
|
|
pct: number;
|
|
pct: number;
|
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
|
|
|
+ locationReadingsMap: Record<number, LocationHASensorReading[]>;
|
|
|
currencySymbol: string;
|
|
currencySymbol: string;
|
|
|
dateFormat: DateFormat;
|
|
dateFormat: DateFormat;
|
|
|
t: TFn;
|
|
t: TFn;
|
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
|
|
|
+ colorizeLocationSensors: boolean;
|
|
|
|
|
+ locationSensorAboveColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorBelowColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorOptimalColor: LocationSensorAlertColor;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// Column header labels (25 columns — matching SpoolBuddy exactly)
|
|
// Column header labels (25 columns — matching SpoolBuddy exactly)
|
|
@@ -188,6 +215,9 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
|
|
|
slicer_filament: (t) => t('inventory.slicerFilament'),
|
|
slicer_filament: (t) => t('inventory.slicerFilament'),
|
|
|
location: () => 'Location',
|
|
location: () => 'Location',
|
|
|
storage_location: (t) => t('inventory.storageLocation'),
|
|
storage_location: (t) => t('inventory.storageLocation'),
|
|
|
|
|
+ temperature: (t) => t('inventory.temperature'),
|
|
|
|
|
+ humidity: (t) => t('inventory.humidity'),
|
|
|
|
|
+ battery: (t) => t('inventory.battery'),
|
|
|
label_weight: (t) => t('inventory.labelWeight'),
|
|
label_weight: (t) => t('inventory.labelWeight'),
|
|
|
net: (t) => t('inventory.net'),
|
|
net: (t) => t('inventory.net'),
|
|
|
gross: () => 'Gross',
|
|
gross: () => 'Gross',
|
|
@@ -270,6 +300,48 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
|
|
</span>
|
|
</span>
|
|
|
);
|
|
);
|
|
|
},
|
|
},
|
|
|
|
|
+ temperature: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
|
|
|
|
|
+ const reading = spool.location_id
|
|
|
|
|
+ ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'temperature')
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+ if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span
|
|
|
|
|
+ title={reading.name}
|
|
|
|
|
+ className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {describeLocationSensor(reading, t)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ humidity: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
|
|
|
|
|
+ const reading = spool.location_id
|
|
|
|
|
+ ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'humidity')
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+ if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span
|
|
|
|
|
+ title={reading.name}
|
|
|
|
|
+ className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {describeLocationSensor(reading, t)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ battery: ({ spool, locationReadingsMap, t, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor }) => {
|
|
|
|
|
+ const reading = spool.location_id
|
|
|
|
|
+ ? locationReadingsMap[spool.location_id]?.find((r) => r.device_class === 'battery')
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+ if (!reading) return <span className="text-sm text-bambu-gray/50">-</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span
|
|
|
|
|
+ title={reading.name}
|
|
|
|
|
+ className={`text-sm ${locationSensorCellColor(reading, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor)}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {describeLocationSensor(reading, t)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
label_weight: ({ spool }) => (
|
|
label_weight: ({ spool }) => (
|
|
|
<span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
|
|
<span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
|
|
|
),
|
|
),
|
|
@@ -404,7 +476,14 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// Sort value extractors — return a comparable value for each sortable column
|
|
// Sort value extractors — return a comparable value for each sortable column
|
|
|
-const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, LocationDisplay>) => string | number> = {
|
|
|
|
|
|
|
+const columnSortValues: Record<
|
|
|
|
|
+ string,
|
|
|
|
|
+ (
|
|
|
|
|
+ spool: InventorySpool,
|
|
|
|
|
+ assignmentMap: Record<number, LocationDisplay>,
|
|
|
|
|
+ locationReadingsMap: Record<number, LocationHASensorReading[]>
|
|
|
|
|
+ ) => string | number
|
|
|
|
|
+> = {
|
|
|
id: (s) => s.id,
|
|
id: (s) => s.id,
|
|
|
added_time: (s) => s.created_at || '',
|
|
added_time: (s) => s.created_at || '',
|
|
|
encode_time: (s) => s.encode_time || '',
|
|
encode_time: (s) => s.encode_time || '',
|
|
@@ -443,6 +522,18 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
|
|
|
const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
|
|
const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
|
|
|
return Math.abs(s.last_scale_weight - expectedGross);
|
|
return Math.abs(s.last_scale_weight - expectedGross);
|
|
|
},
|
|
},
|
|
|
|
|
+ temperature: (s, _am, lrm) => {
|
|
|
|
|
+ const readings = s.location_id ? lrm[s.location_id] : undefined;
|
|
|
|
|
+ return readings?.find((r) => r.device_class === 'temperature')?.value ?? -Infinity;
|
|
|
|
|
+ },
|
|
|
|
|
+ humidity: (s, _am, lrm) => {
|
|
|
|
|
+ const readings = s.location_id ? lrm[s.location_id] : undefined;
|
|
|
|
|
+ return readings?.find((r) => r.device_class === 'humidity')?.value ?? -Infinity;
|
|
|
|
|
+ },
|
|
|
|
|
+ battery: (s, _am, lrm) => {
|
|
|
|
|
+ const readings = s.location_id ? lrm[s.location_id] : undefined;
|
|
|
|
|
+ return readings?.find((r) => r.device_class === 'battery')?.value ?? -Infinity;
|
|
|
|
|
+ },
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const SORT_STATE_KEY = 'bambuddy-inventory-sort';
|
|
const SORT_STATE_KEY = 'bambuddy-inventory-sort';
|
|
@@ -562,6 +653,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const dateFormat: DateFormat = settings?.date_format || 'system';
|
|
const dateFormat: DateFormat = settings?.date_format || 'system';
|
|
|
|
|
+ const locationSensorPollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
|
|
|
|
|
+
|
|
|
|
|
+ const {
|
|
|
|
|
+ colorize: colorizeLocationSensors,
|
|
|
|
|
+ aboveColor: locationSensorAboveColor,
|
|
|
|
|
+ belowColor: locationSensorBelowColor,
|
|
|
|
|
+ optimalColor: locationSensorOptimalColor,
|
|
|
|
|
+ } = useLocationSensorColorPrefs();
|
|
|
|
|
|
|
|
// Query key and fetch function differ based on data source
|
|
// Query key and fetch function differ based on data source
|
|
|
const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
|
|
const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
|
|
@@ -1056,6 +1155,58 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
return map;
|
|
return map;
|
|
|
}, [catalogEntries]);
|
|
}, [catalogEntries]);
|
|
|
|
|
|
|
|
|
|
+ // Not polled — it only changes via explicit create/edit/delete, all of
|
|
|
|
|
+ // which already invalidate this key, and it also seeds the SpoolCard
|
|
|
|
|
+ // footers below (same query key, so they share this fetch instead of each
|
|
|
|
|
+ // issuing their own).
|
|
|
|
|
+ const { data: locationHaSensorsList } = useQuery({
|
|
|
|
|
+ queryKey: ['locationHaSensors'],
|
|
|
|
|
+ queryFn: () => api.getLocationHASensors(),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const locationIdsWithSensors = useMemo(
|
|
|
|
|
+ () => new Set((locationHaSensorsList ?? []).map((s) => s.location_id)),
|
|
|
|
|
+ [locationHaSensorsList]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const usedLocationIds = useMemo(() => {
|
|
|
|
|
+ const ids = new Set<number>();
|
|
|
|
|
+ for (const s of spools || []) {
|
|
|
|
|
+ // Skip locations with no bound sensor at all — polling them would only
|
|
|
|
|
+ // ever come back empty, and most installs have far more storage
|
|
|
|
|
+ // locations than ones actually wired up to Home Assistant.
|
|
|
|
|
+ if (s.location_id && locationIdsWithSensors.has(s.location_id)) ids.add(s.location_id);
|
|
|
|
|
+ }
|
|
|
|
|
+ return Array.from(ids);
|
|
|
|
|
+ }, [spools, locationIdsWithSensors]);
|
|
|
|
|
+
|
|
|
|
|
+ // Card view always needs readings (the SpoolCard footer below reads this
|
|
|
|
|
+ // same cache and filters to show_on_card itself); table view only needs
|
|
|
|
|
+ // them when a sensor column is actually visible, since the default
|
|
|
|
|
+ // column config hides all three.
|
|
|
|
|
+ const needsLocationReadings =
|
|
|
|
|
+ viewMode === 'cards' ||
|
|
|
|
|
+ (viewMode === 'table' &&
|
|
|
|
|
+ columnConfig.some((c) => c.visible && (c.id === 'temperature' || c.id === 'humidity' || c.id === 'battery')));
|
|
|
|
|
+
|
|
|
|
|
+ const locationReadingsQueries = useQueries({
|
|
|
|
|
+ queries: usedLocationIds.map((locationId) => ({
|
|
|
|
|
+ queryKey: ['locationHaSensorReadings', locationId],
|
|
|
|
|
+ queryFn: () => api.getLocationHASensorReadings(locationId, false),
|
|
|
|
|
+ refetchInterval: locationSensorPollIntervalMs,
|
|
|
|
|
+ enabled: needsLocationReadings,
|
|
|
|
|
+ })),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const locationReadingsMap = useMemo(() => {
|
|
|
|
|
+ const map: Record<number, LocationHASensorReading[]> = {};
|
|
|
|
|
+ usedLocationIds.forEach((locationId, i) => {
|
|
|
|
|
+ const data = locationReadingsQueries[i]?.data;
|
|
|
|
|
+ if (data) map[locationId] = data;
|
|
|
|
|
+ });
|
|
|
|
|
+ return map;
|
|
|
|
|
+ }, [usedLocationIds, locationReadingsQueries]);
|
|
|
|
|
+
|
|
|
// Top materials by weight for stat card pills
|
|
// Top materials by weight for stat card pills
|
|
|
const topMaterials = useMemo(() => {
|
|
const topMaterials = useMemo(() => {
|
|
|
if (!stats) return [];
|
|
if (!stats) return [];
|
|
@@ -1209,14 +1360,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
const extractor = columnSortValues[sortState.column];
|
|
const extractor = columnSortValues[sortState.column];
|
|
|
if (!extractor) return filteredSpools;
|
|
if (!extractor) return filteredSpools;
|
|
|
const sorted = [...filteredSpools].sort((a, b) => {
|
|
const sorted = [...filteredSpools].sort((a, b) => {
|
|
|
- const va = extractor(a, assignmentMap);
|
|
|
|
|
- const vb = extractor(b, assignmentMap);
|
|
|
|
|
|
|
+ const va = extractor(a, assignmentMap, locationReadingsMap);
|
|
|
|
|
+ const vb = extractor(b, assignmentMap, locationReadingsMap);
|
|
|
if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
|
|
if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
|
|
|
if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
|
|
if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
|
|
|
return 0;
|
|
return 0;
|
|
|
});
|
|
});
|
|
|
return sorted;
|
|
return sorted;
|
|
|
- }, [filteredSpools, sortState, assignmentMap]);
|
|
|
|
|
|
|
+ }, [filteredSpools, sortState, assignmentMap, locationReadingsMap]);
|
|
|
|
|
|
|
|
// Group similar spools when toggle is active
|
|
// Group similar spools when toggle is active
|
|
|
const displayItems = useMemo((): DisplayItem[] => {
|
|
const displayItems = useMemo((): DisplayItem[] => {
|
|
@@ -1942,6 +2093,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
|
|
onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
|
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ colorizeLocationSensors={colorizeLocationSensors}
|
|
|
|
|
+ locationSensorAboveColor={locationSensorAboveColor}
|
|
|
|
|
+ locationSensorBelowColor={locationSensorBelowColor}
|
|
|
|
|
+ locationSensorOptimalColor={locationSensorOptimalColor}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -1963,6 +2118,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
|
|
onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
|
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
|
|
|
t={t}
|
|
t={t}
|
|
|
|
|
+ colorizeLocationSensors={colorizeLocationSensors}
|
|
|
|
|
+ locationSensorAboveColor={locationSensorAboveColor}
|
|
|
|
|
+ locationSensorBelowColor={locationSensorBelowColor}
|
|
|
|
|
+ locationSensorOptimalColor={locationSensorOptimalColor}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -2088,10 +2247,15 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
visibleColumns={visibleColumns}
|
|
visibleColumns={visibleColumns}
|
|
|
assignmentMap={assignmentMap}
|
|
assignmentMap={assignmentMap}
|
|
|
catalogMap={catalogMap}
|
|
catalogMap={catalogMap}
|
|
|
|
|
+ locationReadingsMap={locationReadingsMap}
|
|
|
currencySymbol={currencySymbol}
|
|
currencySymbol={currencySymbol}
|
|
|
dateFormat={dateFormat}
|
|
dateFormat={dateFormat}
|
|
|
t={t}
|
|
t={t}
|
|
|
onSyncWeight={handleSyncWeight}
|
|
onSyncWeight={handleSyncWeight}
|
|
|
|
|
+ colorizeLocationSensors={colorizeLocationSensors}
|
|
|
|
|
+ locationSensorAboveColor={locationSensorAboveColor}
|
|
|
|
|
+ locationSensorBelowColor={locationSensorBelowColor}
|
|
|
|
|
+ locationSensorOptimalColor={locationSensorOptimalColor}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
@@ -2116,10 +2280,15 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
|
|
|
visibleColumns={visibleColumns}
|
|
visibleColumns={visibleColumns}
|
|
|
assignmentMap={assignmentMap}
|
|
assignmentMap={assignmentMap}
|
|
|
catalogMap={catalogMap}
|
|
catalogMap={catalogMap}
|
|
|
|
|
+ locationReadingsMap={locationReadingsMap}
|
|
|
currencySymbol={currencySymbol}
|
|
currencySymbol={currencySymbol}
|
|
|
dateFormat={dateFormat}
|
|
dateFormat={dateFormat}
|
|
|
t={t}
|
|
t={t}
|
|
|
onSyncWeight={handleSyncWeight}
|
|
onSyncWeight={handleSyncWeight}
|
|
|
|
|
+ colorizeLocationSensors={colorizeLocationSensors}
|
|
|
|
|
+ locationSensorAboveColor={locationSensorAboveColor}
|
|
|
|
|
+ locationSensorBelowColor={locationSensorBelowColor}
|
|
|
|
|
+ locationSensorOptimalColor={locationSensorOptimalColor}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
@@ -2420,6 +2589,7 @@ function PaginationBar({
|
|
|
/* Spool card for cards view */
|
|
/* Spool card for cards view */
|
|
|
function SpoolCard({
|
|
function SpoolCard({
|
|
|
spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
|
|
spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
|
|
|
|
|
+ colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
|
|
|
}: {
|
|
}: {
|
|
|
spool: InventorySpool;
|
|
spool: InventorySpool;
|
|
|
remaining: number;
|
|
remaining: number;
|
|
@@ -2428,6 +2598,10 @@ function SpoolCard({
|
|
|
onPrintLabel?: () => void;
|
|
onPrintLabel?: () => void;
|
|
|
onCopy?: () => void;
|
|
onCopy?: () => void;
|
|
|
t: (key: string, opts?: Record<string, unknown>) => string;
|
|
t: (key: string, opts?: Record<string, unknown>) => string;
|
|
|
|
|
+ colorizeLocationSensors: boolean;
|
|
|
|
|
+ locationSensorAboveColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorBelowColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorOptimalColor: LocationSensorAlertColor;
|
|
|
}) {
|
|
}) {
|
|
|
const bannerStyle = buildFilamentBackground({
|
|
const bannerStyle = buildFilamentBackground({
|
|
|
rgba: spool.rgba,
|
|
rgba: spool.rgba,
|
|
@@ -2509,9 +2683,20 @@ function SpoolCard({
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {spool.location_id && (
|
|
|
|
|
+ <SpoolLocationFooter
|
|
|
|
|
+ locationId={spool.location_id}
|
|
|
|
|
+ locationName={spool.storage_location ?? null}
|
|
|
|
|
+ isLast={!spool.note}
|
|
|
|
|
+ colorize={colorizeLocationSensors}
|
|
|
|
|
+ aboveColor={locationSensorAboveColor}
|
|
|
|
|
+ belowColor={locationSensorBelowColor}
|
|
|
|
|
+ optimalColor={locationSensorOptimalColor}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
{spool.note && (
|
|
{spool.note && (
|
|
|
<div
|
|
<div
|
|
|
- className="text-xs text-bambu-gray/60 pt-2 border-t border-bambu-dark-tertiary truncate"
|
|
|
|
|
|
|
+ className="text-xs text-bambu-gray/60 pt-3 border-t border-bambu-dark-tertiary truncate"
|
|
|
title={spool.note}
|
|
title={spool.note}
|
|
|
>
|
|
>
|
|
|
{spool.note}
|
|
{spool.note}
|
|
@@ -2522,11 +2707,132 @@ function SpoolCard({
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const LOCATION_SENSOR_CATEGORY_ORDER: Record<string, number> = {
|
|
|
|
|
+ temperature: 0,
|
|
|
|
|
+ humidity: 1,
|
|
|
|
|
+ battery: 2,
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function locationSensorIconGapClass(deviceClass: string | null): string {
|
|
|
|
|
+ if (deviceClass === 'humidity') return 'mr-[2px]';
|
|
|
|
|
+ if (deviceClass === 'battery') return 'mr-[3px]';
|
|
|
|
|
+ return '';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Two decimal places, unlike the printer row's raw value: keeps
|
|
|
|
|
+// temperature/humidity/battery cells at a consistent width in the table and
|
|
|
|
|
+// card grid (see describeHASensorReading's doc comment).
|
|
|
|
|
+function describeLocationSensor(
|
|
|
|
|
+ reading: LocationHASensorReading,
|
|
|
|
|
+ t: (key: string, opts?: Record<string, unknown>) => string
|
|
|
|
|
+): string {
|
|
|
|
|
+ return describeHASensorReading(reading, t, { decimals: 2 });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function locationSensorCellColor(
|
|
|
|
|
+ reading: LocationHASensorReading,
|
|
|
|
|
+ colorize: boolean,
|
|
|
|
|
+ aboveColor: LocationSensorAlertColor,
|
|
|
|
|
+ belowColor: LocationSensorAlertColor,
|
|
|
|
|
+ optimalColor: LocationSensorAlertColor
|
|
|
|
|
+): string {
|
|
|
|
|
+ if (!colorize) return 'text-bambu-gray';
|
|
|
|
|
+ const status = locationSensorReadingAlertStatus(reading);
|
|
|
|
|
+ return locationSensorValueColorClass(status, aboveColor, belowColor, optimalColor) || 'text-bambu-gray';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function SpoolLocationFooter({
|
|
|
|
|
+ locationId, locationName, isLast, colorize, aboveColor, belowColor, optimalColor,
|
|
|
|
|
+}: {
|
|
|
|
|
+ locationId: number;
|
|
|
|
|
+ locationName: string | null;
|
|
|
|
|
+ isLast: boolean;
|
|
|
|
|
+ colorize: boolean;
|
|
|
|
|
+ aboveColor: LocationSensorAlertColor;
|
|
|
|
|
+ belowColor: LocationSensorAlertColor;
|
|
|
|
|
+ optimalColor: LocationSensorAlertColor;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const { t } = useTranslation();
|
|
|
|
|
+
|
|
|
|
|
+ const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
|
|
|
|
|
+ const pollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
|
|
|
|
|
+
|
|
|
|
|
+ // Same query key as the list fetch in InventoryPage's body, so this is a
|
|
|
|
|
+ // cache read (no extra request) whenever that has already run — which it
|
|
|
|
|
+ // has, since card view and this footer only render after spools/locations
|
|
|
|
|
+ // are loaded.
|
|
|
|
|
+ const { data: sensorsList } = useQuery({
|
|
|
|
|
+ queryKey: ['locationHaSensors'],
|
|
|
|
|
+ queryFn: () => api.getLocationHASensors(),
|
|
|
|
|
+ });
|
|
|
|
|
+ const hasSensor = (sensorsList ?? []).some((s) => s.location_id === locationId);
|
|
|
|
|
+
|
|
|
|
|
+ // Same query key as the table-view columns' fetch in InventoryPage's body
|
|
|
|
|
+ // (unfiltered, show_on_card=false) — this is a cache read whenever that
|
|
|
|
|
+ // has already run, and the two views never need two different requests
|
|
|
|
|
+ // for one location. Filtering to card-visible sensors happens here
|
|
|
|
|
+ // instead of on the server.
|
|
|
|
|
+ const { data: allReadings } = useQuery({
|
|
|
|
|
+ queryKey: ['locationHaSensorReadings', locationId],
|
|
|
|
|
+ queryFn: () => api.getLocationHASensorReadings(locationId, false),
|
|
|
|
|
+ refetchInterval: pollIntervalMs,
|
|
|
|
|
+ enabled: hasSensor,
|
|
|
|
|
+ });
|
|
|
|
|
+ const readings = allReadings?.filter((r) => r.show_on_card);
|
|
|
|
|
+
|
|
|
|
|
+ if (!readings?.length) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const batteryReading = readings.find((r) => r.device_class === 'battery');
|
|
|
|
|
+ const otherReadings = readings
|
|
|
|
|
+ .filter((r) => r.device_class !== 'battery')
|
|
|
|
|
+ .sort(
|
|
|
|
|
+ (a, b) =>
|
|
|
|
|
+ (LOCATION_SENSOR_CATEGORY_ORDER[a.device_class ?? ''] ?? 99) -
|
|
|
|
|
+ (LOCATION_SENSOR_CATEGORY_ORDER[b.device_class ?? ''] ?? 99)
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`flex items-center gap-2 pt-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray ${isLast ? '-mb-1' : ''}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {locationName && <span className="truncate">{locationName}</span>}
|
|
|
|
|
+ <span className="text-bambu-gray/40">|</span>
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ {otherReadings.map((reading, index) => {
|
|
|
|
|
+ const Icon = iconForHASensor(reading);
|
|
|
|
|
+ const firstIconOffsetClass = index === 0 ? 'ml-[-3.6px]' : '';
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span key={reading.id} title={reading.name} className="flex items-center gap-[3px]">
|
|
|
|
|
+ <Icon className={`w-3 h-3 ${locationSensorIconGapClass(reading.device_class)} ${firstIconOffsetClass}`} />
|
|
|
|
|
+ <span className={locationSensorCellColor(reading, colorize, aboveColor, belowColor, optimalColor)}>
|
|
|
|
|
+ {describeLocationSensor(reading, t)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {batteryReading &&
|
|
|
|
|
+ (() => {
|
|
|
|
|
+ const Icon = iconForHASensor(batteryReading);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span key={batteryReading.id} title={batteryReading.name} className="flex items-center gap-[3px] ml-auto">
|
|
|
|
|
+ <Icon className={`w-3 h-3 ${locationSensorIconGapClass(batteryReading.device_class)}`} />
|
|
|
|
|
+ <span className={locationSensorCellColor(batteryReading, colorize, aboveColor, belowColor, optimalColor)}>
|
|
|
|
|
+ {describeLocationSensor(batteryReading, t)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* Single spool row for table view */
|
|
/* Single spool row for table view */
|
|
|
function SpoolTableRow({
|
|
function SpoolTableRow({
|
|
|
spool, remaining, pct, isSelected, onToggleSelected,
|
|
spool, remaining, pct, isSelected, onToggleSelected,
|
|
|
onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
|
- visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
|
|
|
|
+ visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
|
|
+ colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
|
|
|
}: {
|
|
}: {
|
|
|
spool: InventorySpool;
|
|
spool: InventorySpool;
|
|
|
remaining: number;
|
|
remaining: number;
|
|
@@ -2543,10 +2849,15 @@ function SpoolTableRow({
|
|
|
visibleColumns: string[];
|
|
visibleColumns: string[];
|
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
|
|
|
+ locationReadingsMap: Record<number, LocationHASensorReading[]>;
|
|
|
currencySymbol: string;
|
|
currencySymbol: string;
|
|
|
dateFormat: DateFormat;
|
|
dateFormat: DateFormat;
|
|
|
t: TFn;
|
|
t: TFn;
|
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
|
|
|
+ colorizeLocationSensors: boolean;
|
|
|
|
|
+ locationSensorAboveColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorBelowColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorOptimalColor: LocationSensorAlertColor;
|
|
|
}) {
|
|
}) {
|
|
|
return (
|
|
return (
|
|
|
<tr
|
|
<tr
|
|
@@ -2568,7 +2879,7 @@ function SpoolTableRow({
|
|
|
</td>
|
|
</td>
|
|
|
{visibleColumns.map((colId) => (
|
|
{visibleColumns.map((colId) => (
|
|
|
<td key={colId} className="py-3 px-4">
|
|
<td key={colId} className="py-3 px-4">
|
|
|
- {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
|
|
|
|
|
|
|
+ {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })}
|
|
|
</td>
|
|
</td>
|
|
|
))}
|
|
))}
|
|
|
<td className="py-3 px-4">
|
|
<td className="py-3 px-4">
|
|
@@ -2617,7 +2928,8 @@ function SpoolTableRow({
|
|
|
function SpoolTableGroup({
|
|
function SpoolTableGroup({
|
|
|
spools, headerSpool, remaining, pct, isExpanded, onToggle,
|
|
spools, headerSpool, remaining, pct, isExpanded, onToggle,
|
|
|
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
onEdit, onCopy, onArchive, onDelete, onPrintLabel, onResetConsumedCounter,
|
|
|
- visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
|
|
|
|
+ visibleColumns, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight,
|
|
|
|
|
+ colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor,
|
|
|
selectedIds, onToggleSelected, onToggleGroupSelected,
|
|
selectedIds, onToggleSelected, onToggleGroupSelected,
|
|
|
}: {
|
|
}: {
|
|
|
spools: InventorySpool[];
|
|
spools: InventorySpool[];
|
|
@@ -2637,10 +2949,15 @@ function SpoolTableGroup({
|
|
|
visibleColumns: string[];
|
|
visibleColumns: string[];
|
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
assignmentMap: Record<number, LocationDisplay>;
|
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
catalogMap: Record<number, SpoolCatalogEntry>;
|
|
|
|
|
+ locationReadingsMap: Record<number, LocationHASensorReading[]>;
|
|
|
currencySymbol: string;
|
|
currencySymbol: string;
|
|
|
dateFormat: DateFormat;
|
|
dateFormat: DateFormat;
|
|
|
t: TFn;
|
|
t: TFn;
|
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
onSyncWeight?: (spool: InventorySpool) => void;
|
|
|
|
|
+ colorizeLocationSensors: boolean;
|
|
|
|
|
+ locationSensorAboveColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorBelowColor: LocationSensorAlertColor;
|
|
|
|
|
+ locationSensorOptimalColor: LocationSensorAlertColor;
|
|
|
selectedIds?: Set<number>;
|
|
selectedIds?: Set<number>;
|
|
|
onToggleSelected?: (id: number) => void;
|
|
onToggleSelected?: (id: number) => void;
|
|
|
onToggleGroupSelected?: (ids: number[], select: boolean) => void;
|
|
onToggleGroupSelected?: (ids: number[], select: boolean) => void;
|
|
@@ -2669,14 +2986,14 @@ function SpoolTableGroup({
|
|
|
{idx === 0 ? (
|
|
{idx === 0 ? (
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
|
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
|
|
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
|
|
|
- {columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })}
|
|
|
|
|
|
|
+ {columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })}
|
|
|
</div>
|
|
</div>
|
|
|
) : colId === 'id' ? (
|
|
) : colId === 'id' ? (
|
|
|
<span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
|
|
<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 })}
|
|
{t('inventory.groupedSpools', { count: spools.length })}
|
|
|
</span>
|
|
</span>
|
|
|
) : (
|
|
) : (
|
|
|
- columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight })
|
|
|
|
|
|
|
+ columnCells[colId]?.({ spool: headerSpool, remaining, pct, assignmentMap, catalogMap, locationReadingsMap, currencySymbol, dateFormat, t, onSyncWeight, colorizeLocationSensors, locationSensorAboveColor, locationSensorBelowColor, locationSensorOptimalColor })
|
|
|
)}
|
|
)}
|
|
|
</td>
|
|
</td>
|
|
|
))}
|
|
))}
|
|
@@ -2708,10 +3025,15 @@ function SpoolTableGroup({
|
|
|
visibleColumns={visibleColumns}
|
|
visibleColumns={visibleColumns}
|
|
|
assignmentMap={assignmentMap}
|
|
assignmentMap={assignmentMap}
|
|
|
catalogMap={catalogMap}
|
|
catalogMap={catalogMap}
|
|
|
|
|
+ locationReadingsMap={locationReadingsMap}
|
|
|
currencySymbol={currencySymbol}
|
|
currencySymbol={currencySymbol}
|
|
|
dateFormat={dateFormat}
|
|
dateFormat={dateFormat}
|
|
|
t={t}
|
|
t={t}
|
|
|
onSyncWeight={onSyncWeight}
|
|
onSyncWeight={onSyncWeight}
|
|
|
|
|
+ colorizeLocationSensors={colorizeLocationSensors}
|
|
|
|
|
+ locationSensorAboveColor={locationSensorAboveColor}
|
|
|
|
|
+ locationSensorBelowColor={locationSensorBelowColor}
|
|
|
|
|
+ locationSensorOptimalColor={locationSensorOptimalColor}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|