From cc5afe3fbc7aa21bf8393ef265fa24e67fee807d Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 18 Jun 2026 13:56:53 -0600 Subject: [PATCH] Add responsive mobile view for Inventory with configurable columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 //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 — mobile vs tablet+. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/inventory/FieldSchemaEditor.js | 16 ++++- .../inventory/InventoryItemsMobile.js | 61 ++++++++++++++++ .../inventory/InventoryItemsMobile.test.js | 72 +++++++++++++++++++ .../components/inventory/InventoryRoute.js | 15 +++- .../components/inventory/InventoryTable.js | 29 ++++---- modules/inventory/defaults.py | 26 +++---- 6 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 app/src/components/inventory/InventoryItemsMobile.js create mode 100644 app/src/components/inventory/InventoryItemsMobile.test.js diff --git a/app/src/components/inventory/FieldSchemaEditor.js b/app/src/components/inventory/FieldSchemaEditor.js index 2c9860a0..30becf0a 100644 --- a/app/src/components/inventory/FieldSchemaEditor.js +++ b/app/src/components/inventory/FieldSchemaEditor.js @@ -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}) { Order - Label - Type + Label + Type Unit / Options + + Mobile + @@ -96,6 +102,10 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) { options: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}/>} + + update(index, {mobile: data.checked})}/> + diff --git a/app/src/components/inventory/InventoryItemsMobile.js b/app/src/components/inventory/InventoryItemsMobile.js new file mode 100644 index 00000000..165fbde6 --- /dev/null +++ b/app/src/components/inventory/InventoryItemsMobile.js @@ -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 <> +

+ Read-only — rotate to landscape to edit. +

+ {(items || []).length === 0 + ?

No items yet.

+ : + + + {columns.map(f => {f.label})} + + + + {items.map(item => { + const expired = isItemExpired(item, fields); + return + {columns.map((f, idx) => + {idx === 0 && expired && + } + {formatValue(item, f)} + )} + ; + })} + +
} + ; +} diff --git a/app/src/components/inventory/InventoryItemsMobile.test.js b/app/src/components/inventory/InventoryItemsMobile.test.js new file mode 100644 index 00000000..9d644674 --- /dev/null +++ b/app/src/components/inventory/InventoryItemsMobile.test.js @@ -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(); + + 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(); + + 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(); + 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(); + + 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(); + expect(screen.getByText('No items yet.')).toBeTruthy(); + }); +}); diff --git a/app/src/components/inventory/InventoryRoute.js b/app/src/components/inventory/InventoryRoute.js index 833e6cbc..bd57cf22 100644 --- a/app/src/components/inventory/InventoryRoute.js +++ b/app/src/components/inventory/InventoryRoute.js @@ -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() { {tab === 'items' - ? persistInventory(slug, {items: newItems})}/> + ? <> + {/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */} + + + + + persistInventory(slug, {items: newItems})}/> + + : } 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(); + }); +} diff --git a/modules/inventory/defaults.py b/modules/inventory/defaults.py index 5ece650f..da75f6da 100644 --- a/modules/inventory/defaults.py +++ b/modules/inventory/defaults.py @@ -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), ], }