mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Add responsive mobile view for Inventory with configurable columns
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:
parent
3d6aead072
commit
cc5afe3fbc
6 changed files with 190 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
61
app/src/components/inventory/InventoryItemsMobile.js
Normal file
61
app/src/components/inventory/InventoryItemsMobile.js
Normal 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>}
|
||||
</>;
|
||||
}
|
||||
72
app/src/components/inventory/InventoryItemsMobile.test.js
Normal file
72
app/src/components/inventory/InventoryItemsMobile.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue