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),
],
}