wrolpi/app/src/components/inventory/InventoryTable.js
Roland 048fea07ca
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:15:57 -06:00

331 lines
14 KiB
JavaScript

import React, {useMemo, useRef, useState} from "react";
import {Button, Checkbox, Icon, Table} from "../ui";
import {FieldCell} from "./FieldCell";
import {applyComputedCounts, computedCountConfigs} from "./computeFields";
import {NUMERIC_TYPES} from "./units";
const LOCATION_LIST_ID = 'inventory-location-suggestions';
const CATALOG_LIST_ID = 'inventory-catalog-names';
// Fields copied from a catalog entry onto the new-item row when its name matches a catalog entry.
const CATALOG_KEYS = ['name', 'category', 'subcategory', 'item_size', 'item_size_unit', 'calories'];
// Build an empty item keyed by the field schema (quantity fields seed their default unit).
function emptyItem(fields) {
const item = {};
fields.forEach(f => {
item[f.key] = '';
if (f.type === 'quantity') {
item[`${f.key}_unit`] = f.unit || '';
}
});
return item;
}
function nextId(items) {
return items.reduce((max, i) => (typeof i.id === 'number' && i.id > max ? i.id : max), 0) + 1;
}
// Type-aware comparison for sorting: numeric for number/quantity/calories, chronological for date, locale string
// otherwise. Blank values sort last (ascending).
function compareByField(a, b, field) {
const av = a[field.key], bv = b[field.key];
const aBlank = av === '' || av == null;
const bBlank = bv === '' || bv == null;
if (aBlank || bBlank) {
return aBlank === bBlank ? 0 : (aBlank ? 1 : -1);
}
if (NUMERIC_TYPES.includes(field.type)) {
return (Number(av) || 0) - (Number(bv) || 0);
}
if (field.type === 'date') {
return (Date.parse(av) || 0) - (Date.parse(bv) || 0);
}
return String(av).localeCompare(String(bv));
}
/**
* Spreadsheet-style item table. All edits happen on the in-memory items; `onChange(newItems)` persists the whole
* inventory (the page holds every inventory and saves the changed one). A persistent "new item" row sits at the
* bottom: Tab moves between fields and Enter submits the item then refocuses the first field.
*/
export function InventoryTable({slug, fields, items, locations, catalog, search, onChange}) {
const [draft, setDraft] = useState(() => emptyItem(fields));
const [edits, setEdits] = useState({}); // itemId -> edited copy
const [selected, setSelected] = useState(new Set());
const [sort, setSort] = useState(null); // {key, dir: 'asc'|'desc'} or null for entry order
const [focusCell, setFocusCell] = useState(null); // {id, key}: the cell to focus + select on entering edit
const firstInputRef = useRef(null);
const hasLocationField = fields.some(f => f.type === 'location');
const hasNameField = fields.some(f => f.key === 'name');
// Catalog lookup by lower-cased name, and the set of field keys this inventory actually has (so pre-fill only
// touches existing fields). Catalog integration only applies when the inventory has a `name` field.
const catalogByName = useMemo(() => {
const map = {};
(catalog || []).forEach(e => {
if (e.name) {
map[e.name.trim().toLowerCase()] = e;
}
});
return map;
}, [catalog]);
const fieldKeySet = useMemo(() => {
const keys = new Set();
fields.forEach(f => {
keys.add(f.key);
if (f.type === 'quantity') {
keys.add(`${f.key}_unit`);
}
});
return keys;
}, [fields]);
// "Count by weight" configs: a Count field auto-filled from a Total Weight ÷ Unit Weight.
const countConfigs = useMemo(() => computedCountConfigs(fields), [fields]);
const listIdFor = (field) => {
if (field.type === 'location') {
return LOCATION_LIST_ID;
}
if (field.key === 'name' && (catalog || []).length) {
return CATALOG_LIST_ID;
}
return undefined;
};
// Re-seed local editing state when the inventory or its schema changes.
React.useEffect(() => {
setDraft(emptyItem(fields));
setEdits({});
setSelected(new Set());
setSort(null);
setFocusCell(null);
}, [slug, fields]);
// Expired = any date-type field strictly before the start of today (shared with the mobile view).
const isExpired = (item) => isItemExpired(item, fields);
const toggleSort = (key) => setSort(prev =>
prev && prev.key === key
? {key, dir: prev.dir === 'asc' ? 'desc' : 'asc'}
: {key, dir: 'asc'});
// Rows are sorted for display only; the persistent entry row lives in the footer and is unaffected.
const displayItems = useMemo(() => {
const list = items || [];
if (!sort) {
return list;
}
const field = fields.find(f => f.key === sort.key);
if (!field) {
return list;
}
const sorted = [...list].sort((a, b) => compareByField(a, b, field));
return sort.dir === 'desc' ? sorted.reverse() : sorted;
}, [items, fields, sort]);
// The search filters which rows are shown; mutations below still operate on the full `items` prop.
const visibleItems = useMemo(() => filterItems(displayItems, fields, search), [displayItems, fields, search]);
const focusFirst = () => {
// Our inputs forward their ref straight to the underlying <input> DOM node (unlike the old Input,
// which wrapped it behind `.inputRef`).
const node = firstInputRef.current;
if (node && node.focus) {
node.focus();
}
};
const setDraftValue = (key, value) => setDraft(prev => {
const next = {...prev, [key]: value};
// When the Name matches a catalog entry, pre-fill the matching fields (category, size, calories, ...).
if (key === 'name' && hasNameField) {
const entry = catalogByName[String(value).trim().toLowerCase()];
if (entry) {
CATALOG_KEYS.forEach(k => {
if (k !== 'name' && fieldKeySet.has(k) && entry[k] != null && entry[k] !== '') {
next[k] = entry[k];
}
});
}
}
// Auto-fill any "count by weight" field when a weight (or its unit) changed.
applyComputedCounts(next, countConfigs, key);
return next;
});
const submitDraft = () => {
const hasValue = Object.entries(draft).some(([k, v]) => !k.endsWith('_unit') && v !== '' && v != null);
if (!hasValue) {
return;
}
onChange([...items, {...draft, id: nextId(items)}]);
setDraft(emptyItem(fields));
focusFirst();
};
const startEdit = (item, key) => {
setEdits(prev => ({...prev, [item.id]: {...item}}));
setFocusCell({id: item.id, key}); // the FieldCell for this key auto-focuses and selects its contents
};
const setEditValue = (id, key, value) =>
setEdits(prev => {
const updated = {...prev[id], [key]: value};
applyComputedCounts(updated, countConfigs, key);
return {...prev, [id]: updated};
});
const saveEdit = (id) => {
onChange(items.map(i => i.id === id ? edits[id] : i));
setEdits(prev => {
const next = {...prev};
delete next[id];
return next;
});
};
const toggleSelected = (id) => setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const removeSelected = () => {
if (selected.size === 0) {
return;
}
onChange(items.filter(i => !selected.has(i.id)));
setSelected(new Set());
};
const sortedFields = [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return <>
{/* Shared location suggestions (pooled across all inventories), referenced by location-field inputs. */}
{hasLocationField &&
<datalist id={LOCATION_LIST_ID}>
{(locations || []).map(l => <option key={l} value={l}/>)}
</datalist>}
{/* Food-catalog name suggestions; selecting one pre-fills the matching fields. */}
{hasNameField && (catalog || []).length > 0 &&
<datalist id={CATALOG_LIST_ID}>
{catalog.map(e => <option key={e.id ?? e.name} value={e.name}/>)}
</datalist>}
{selected.size > 0 &&
<Button role='danger' onClick={removeSelected} style={{marginBottom: '0.5em'}}>
Delete {selected.size} selected
</Button>}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{sortedFields.map(f => <Table.HeaderCell
key={f.key}
sorted={sort && sort.key === f.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onSort={() => toggleSort(f.key)}>
{f.label}
</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{visibleItems.map(item => {
const editing = edits[item.id];
const expired = isExpired(item);
return <Table.Row key={item.id} failed={expired}>
<Table.Cell>
<Checkbox checked={selected.has(item.id)} onChange={() => toggleSelected(item.id)}/>
{expired && <Icon name='warning sign' label='Expired'
style={{color: 'var(--danger)', marginLeft: '0.4em'}}/>}
</Table.Cell>
{sortedFields.map(f => <Table.Cell key={f.key}>
{editing
? <FieldCell
field={f}
value={editing[f.key]}
unitValue={editing[`${f.key}_unit`]}
onChange={v => setEditValue(item.id, f.key, v)}
onUnitChange={v => setEditValue(item.id, `${f.key}_unit`, v)}
onEnter={() => saveEdit(item.id)}
listId={listIdFor(f)}
autoFocus={focusCell && focusCell.id === item.id && focusCell.key === f.key}
/>
: <span onClick={() => startEdit(item, f.key)} style={{cursor: 'pointer'}}>
{formatValue(item, f)}
</span>}
</Table.Cell>)}
</Table.Row>;
})}
{search && visibleItems.length === 0 && (items || []).length > 0 &&
<Table.Row>
<Table.Cell colSpan={sortedFields.length + 1} style={{opacity: 0.7}}>
No items match "{search}".
</Table.Cell>
</Table.Row>}
</Table.Body>
<Table.Footer>
{/* The persistent new-item entry row. */}
<Table.Row>
<Table.Cell>
<Button role='primary' size='xs' onClick={submitDraft} aria-label='Add item'>+</Button>
</Table.Cell>
{sortedFields.map((f, index) => <Table.Cell key={f.key}>
<FieldCell
field={f}
value={draft[f.key]}
unitValue={draft[`${f.key}_unit`]}
onChange={v => setDraftValue(f.key, v)}
onUnitChange={v => setDraftValue(`${f.key}_unit`, v)}
onEnter={submitDraft}
inputRef={index === 0 ? firstInputRef : undefined}
listId={listIdFor(f)}
/>
</Table.Cell>)}
</Table.Row>
</Table.Footer>
</Table>
</>;
}
// Format an item's value for a field for display (shared with the read-only mobile view).
export function formatValue(item, field) {
const value = item[field.key];
if (value === '' || value == null) {
return '';
}
if (field.type === 'quantity') {
const unit = item[`${field.key}_unit`] || field.unit || '';
return `${value} ${unit}`.trim();
}
return String(value);
}
// True when any `date`-type field is before the start of today (item is expired).
export function isItemExpired(item, fields) {
const start = new Date();
start.setHours(0, 0, 0, 0);
return (fields || []).some(f => {
if (f.type !== 'date') {
return false;
}
const t = Date.parse(item[f.key]);
return !isNaN(t) && t < start.getTime();
});
}
/**
* Filter items by a free-text search across every column's displayed value (using the same formatting as the
* table, so quantities match as "30 lb", dates as "2030-01-01", etc.). Case-insensitive; whitespace-separated
* terms are AND-ed. A blank search returns all items. Shared by the Items table, Summary, and export views.
*/
export function filterItems(items, fields, search) {
const query = (search || '').trim().toLowerCase();
if (!query) {
return items || [];
}
const terms = query.split(/\s+/);
const cols = [...(fields || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (items || []).filter(item => {
const haystack = cols.map(f => formatValue(item, f)).join(' ').toLowerCase();
return terms.every(term => haystack.includes(term));
});
}