mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
331 lines
14 KiB
JavaScript
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));
|
|
});
|
|
}
|