Add responsive mobile view for Inventory with configurable columns
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

Portrait mobile shows a condensed, read-only table; landscape/tablet+ keeps
the full editable spreadsheet.  The columns shown on mobile are configurable
per field via a new "Mobile" toggle in the Customize Fields editor.

- defaults.py: seed `mobile` on each type's default columns (food: Name,
  Subcategory, Size, Count; fuel/tool: sensible analogs).
- FieldSchemaEditor: per-field Mobile toggle (persists through PUT /<slug>/fields).
- InventoryItemsMobile: read-only condensed view driven by the `mobile` flag,
  falling back to a default column set for schemas predating the flag.
- InventoryRoute: split Items tab via <Media> — mobile vs tablet+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-06-18 13:56:53 -06:00
parent 3d6aead072
commit cc5afe3fbc
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 190 additions and 29 deletions

View file

@ -1,5 +1,7 @@
import React, {useState} from "react";
import {Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {
Checkbox, Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow
} from "semantic-ui-react";
import {Button, Icon, Modal, Table} from "../Theme";
import {ALL_UNITS} from "./units";
@ -61,9 +63,13 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
<TableHeader>
<TableRow>
<TableHeaderCell width={2}>Order</TableHeaderCell>
<TableHeaderCell width={4}>Label</TableHeaderCell>
<TableHeaderCell width={4}>Type</TableHeaderCell>
<TableHeaderCell width={3}>Label</TableHeaderCell>
<TableHeaderCell width={3}>Type</TableHeaderCell>
<TableHeaderCell width={5}>Unit / Options</TableHeaderCell>
<TableHeaderCell width={2} textAlign='center'
title='Show this field in the read-only portrait-mobile view'>
Mobile
</TableHeaderCell>
<TableHeaderCell width={1}/>
</TableRow>
</TableHeader>
@ -96,6 +102,10 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
options: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}/>}
</TableCell>
<TableCell collapsing textAlign='center'>
<Checkbox toggle checked={!!f.mobile} aria-label={`Show ${f.label || f.key} on mobile`}
onChange={(e, data) => update(index, {mobile: data.checked})}/>
</TableCell>
<TableCell collapsing>
<Button color='red' icon size='mini' onClick={() => remove(index)}
aria-label='Remove field'><Icon name='trash'/></Button>

View file

@ -0,0 +1,61 @@
import React from "react";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Icon, Table} from "../Theme";
import {formatValue, isItemExpired} from "./InventoryTable";
// Fallback columns for inventories whose schema predates the per-field `mobile` flag: these preferred keys in
// order, else the first few fields.
const FALLBACK_COLUMN_KEYS = ['name', 'subcategory', 'item_size', 'count'];
// The condensed columns for portrait mobile: fields the user flagged `mobile`, falling back to a sensible default
// when an older schema has none flagged.
export function mobileColumns(fields) {
const ordered = [...(fields || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const flagged = ordered.filter(f => f.mobile);
if (flagged.length) {
return flagged;
}
const preferred = ordered.filter(f => FALLBACK_COLUMN_KEYS.includes(f.key));
if (preferred.length >= 4) {
return preferred;
}
// Fewer than 4 preferred keys (e.g. old tool/fuel schemas) — show the first four fields instead.
return ordered.slice(0, 4);
}
/**
* Read-only, condensed inventory view for portrait mobile. Shows only the fields flagged `mobile` (configurable in
* the field-schema editor) and highlights expired rows; editing/sorting/adding happen in the full InventoryTable
* (landscape / tablet+).
*/
export function InventoryItemsMobile({fields, items}) {
const columns = mobileColumns(fields);
return <>
<p style={{opacity: 0.7, fontSize: '0.85em', marginBottom: '0.5em'}}>
Read-only — rotate to landscape to edit.
</p>
{(items || []).length === 0
? <p>No items yet.</p>
: <Table celled unstackable>
<TableHeader>
<TableRow>
{columns.map(f => <TableHeaderCell key={f.key}>{f.label}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
{items.map(item => {
const expired = isItemExpired(item, fields);
return <TableRow key={item.id} negative={expired}>
{columns.map((f, idx) => <TableCell key={f.key}>
{idx === 0 && expired &&
<Icon name='warning sign' color='red' title='Expired'
style={{marginRight: '0.4em'}}/>}
{formatValue(item, f)}
</TableCell>)}
</TableRow>;
})}
</TableBody>
</Table>}
</>;
}

View file

@ -0,0 +1,72 @@
import React from "react";
import {render, screen} from "@testing-library/react";
import {InventoryItemsMobile} from "./InventoryItemsMobile";
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'category', label: 'Category', type: 'text', order: 1},
{key: 'subcategory', label: 'Sub-category', type: 'text', order: 2},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 3},
{key: 'count', label: 'Count', type: 'number', order: 4},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 5},
];
describe('InventoryItemsMobile', () => {
test('shows the fields flagged `mobile`, in field order', () => {
const flagged = [
{key: 'name', label: 'Name', type: 'text', order: 0, mobile: true},
{key: 'category', label: 'Category', type: 'text', order: 1},
{key: 'count', label: 'Count', type: 'number', order: 2, mobile: true},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 3, mobile: true},
];
render(<InventoryItemsMobile fields={flagged} items={[{id: 1, name: 'Rice'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Name', 'Count', 'Expires']);
// An unflagged field is not shown.
expect(screen.queryByText('Category')).toBeNull();
});
test('falls back to default columns when no field is flagged mobile', () => {
render(<InventoryItemsMobile fields={FIELDS} items={[{id: 1, name: 'Rice'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Name', 'Sub-category', 'Size', 'Count']);
expect(screen.queryByText('Category')).toBeNull();
expect(screen.queryByText('Expires')).toBeNull();
});
test('falls back to the first four fields when no preferred keys are present', () => {
const fields = [
{key: 'fuel_type', label: 'Fuel', type: 'text', order: 0},
{key: 'container', label: 'Container', type: 'text', order: 1},
{key: 'volume', label: 'Volume', type: 'quantity', order: 2},
{key: 'purchase_date', label: 'Purchased', type: 'date', order: 3},
{key: 'location', label: 'Location', type: 'location', order: 4},
];
render(<InventoryItemsMobile fields={fields} items={[{id: 1, fuel_type: 'Diesel'}]}/>);
const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent);
expect(headers).toEqual(['Fuel', 'Container', 'Volume', 'Purchased']);
});
test('renders quantity values with their unit and flags expired rows', () => {
const items = [
{id: 1, name: 'Old', subcategory: 'rice', item_size: '5', item_size_unit: 'lb', count: '2',
expiration_date: '2000-01-01'},
{id: 2, name: 'Fresh', subcategory: 'beans', item_size: '10', item_size_unit: 'lb', count: '4',
expiration_date: '2999-01-01'},
];
const {container} = render(<InventoryItemsMobile fields={FIELDS} items={items}/>);
expect(screen.getByText('5 lb')).toBeTruthy();
const rowOf = (name) => screen.getByText(name).closest('tr');
expect(rowOf('Old').className).toContain('negative');
expect(rowOf('Fresh').className).not.toContain('negative');
expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1);
});
test('shows an empty state when there are no items', () => {
render(<InventoryItemsMobile fields={FIELDS} items={[]}/>);
expect(screen.getByText('No items yet.')).toBeTruthy();
});
});

View file

@ -4,9 +4,11 @@ import {Button, Confirm, Header, Icon, Loader, Menu, Modal, Segment} from "../Th
import {PageContainer, useTitle} from "../Common";
import {collectLocations, useCatalog, useInventories} from "../../hooks/customHooks";
import {InventoryTable} from "./InventoryTable";
import {InventoryItemsMobile} from "./InventoryItemsMobile";
import {InventorySummary} from "./InventorySummary";
import {FieldSchemaEditor} from "./FieldSchemaEditor";
import {CatalogEditor} from "./CatalogEditor";
import {Media} from "../../contexts/contexts";
const INVENTORY_TYPES = [
{key: 'food', value: 'food', text: 'Food Storage'},
@ -140,8 +142,17 @@ export function InventoryRoute() {
</Menu>
{tab === 'items'
? <InventoryTable slug={slug} fields={fields} items={items} locations={locations} catalog={catalog}
onChange={newItems => persistInventory(slug, {items: newItems})}/>
? <>
{/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */}
<Media at='mobile'>
<InventoryItemsMobile fields={fields} items={items}/>
</Media>
<Media greaterThanOrEqual='tablet'>
<InventoryTable slug={slug} fields={fields} items={items} locations={locations}
catalog={catalog}
onChange={newItems => persistInventory(slug, {items: newItems})}/>
</Media>
</>
: <InventorySummary fields={fields} items={items}/>}
<FieldSchemaEditor fields={fields} open={editFieldsOpen}

View file

@ -98,17 +98,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, onChang
setSort(null);
}, [slug, fields]);
// Expired = any date-type field strictly before the start of today.
const dateFields = useMemo(() => fields.filter(f => f.type === 'date'), [fields]);
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}, []);
const isExpired = (item) => dateFields.some(f => {
const t = Date.parse(item[f.key]);
return !isNaN(t) && t < todayStart;
});
// 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
@ -273,7 +264,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, onChang
</>;
}
function formatValue(item, field) {
// 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 '';
@ -284,3 +276,16 @@ function formatValue(item, field) {
}
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();
});
}

View file

@ -57,34 +57,36 @@ DEFAULT_FOOD_CATEGORIES = [
_FOOD_CATEGORY_OPTIONS = sorted({category for _, category in DEFAULT_FOOD_CATEGORIES})
DEFAULT_FIELD_SETS = {
# `mobile=True` marks the columns shown in the condensed, read-only portrait-mobile view; the user can change
# which fields are mobile in the field-schema editor.
'food': [
_field('brand', 'Brand', 'text', 0),
_field('name', 'Name', 'text', 1, required=True),
_field('name', 'Name', 'text', 1, required=True, mobile=True),
_field('category', 'Category', 'select', 2, options=_FOOD_CATEGORY_OPTIONS),
_field('subcategory', 'Subcategory', 'text', 3),
_field('item_size', 'Size', 'quantity', 4, unit='lb'),
_field('count', 'Count', 'number', 5),
_field('subcategory', 'Subcategory', 'text', 3, mobile=True),
_field('item_size', 'Size', 'quantity', 4, unit='lb', mobile=True),
_field('count', 'Count', 'number', 5, mobile=True),
_field('calories', 'kcal per unit', 'calories', 6),
_field('expiration_date', 'Expires', 'date', 7),
],
'fuel': [
_field('name', 'Name', 'text', 0, required=True),
_field('name', 'Name', 'text', 0, required=True, mobile=True),
_field('fuel_type', 'Fuel Type', 'select', 1,
options=['gasoline', 'diesel', 'propane', 'kerosene', 'oil']),
options=['gasoline', 'diesel', 'propane', 'kerosene', 'oil'], mobile=True),
_field('container', 'Container', 'text', 2),
_field('item_size', 'Size', 'quantity', 3, unit='gallon'),
_field('count', 'Count', 'number', 4),
_field('item_size', 'Size', 'quantity', 3, unit='gallon', mobile=True),
_field('count', 'Count', 'number', 4, mobile=True),
_field('purchase_date', 'Purchased', 'date', 5),
_field('location', 'Location', 'location', 6),
],
'tool': [
_field('name', 'Name', 'text', 0, required=True),
_field('name', 'Name', 'text', 0, required=True, mobile=True),
_field('brand', 'Brand', 'text', 1),
_field('category', 'Category', 'select', 2,
options=['hand', 'power', 'garden', 'automotive', 'measuring', 'safety']),
_field('count', 'Count', 'number', 3),
options=['hand', 'power', 'garden', 'automotive', 'measuring', 'safety'], mobile=True),
_field('count', 'Count', 'number', 3, mobile=True),
_field('condition', 'Condition', 'select', 4, options=['new', 'good', 'worn', 'broken']),
_field('location', 'Location', 'location', 5),
_field('location', 'Location', 'location', 5, mobile=True),
_field('notes', 'Notes', 'text', 6),
],
}