Compare commits
12 Commits
6f0662271f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63e5871e61 | |||
| 86082c50d1 | |||
| feb44060c4 | |||
| 5091228e6d | |||
| b85fd70793 | |||
| c4cf6a92fe | |||
| b2f34e308e | |||
| 5b31525fbc | |||
| bdacf52b28 | |||
| ffa007082a | |||
| 5a37e5dd5f | |||
| b4f8489834 |
2
.github/copilot-instructions.md
vendored
Normal file
2
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
When talking about the API for this project, use the following filepath to find all the files and documentation : "C:\Source\Programming Projects\pantry-manager-api-csharp"
|
||||||
|
Always update the README with the newest details and changes
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
498
README.md
498
README.md
@@ -1,276 +1,318 @@
|
|||||||
# Webpage Playground
|
# Pantry Management Frontend
|
||||||
|
|
||||||
A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.
|
React + Vite frontend for the ASP.NET Core pantry manager API.
|
||||||
|
|
||||||
## Overview
|
This app is no longer a static demo. It now uses the backend API for authentication, profile data, inventory items, locations, search, and barcode-based item lookup.
|
||||||
|
|
||||||
Webpage Playground is a lightweight, responsive web application for managing and tracking inventory across multiple storage locations (Pantry, Fridge, Freezer). The app includes:
|
## What Changed
|
||||||
|
|
||||||
- **Dynamic Navigation** — Easy page switching with a responsive navbar
|
- Replaced local demo inventory data with live API calls.
|
||||||
- **Inventory Demo** — Display items using reusable item components
|
- Added a shared API client with JWT handling, refresh-token retry, and normalized error messages.
|
||||||
- **Advanced Search** — Filter inventory by name, location, quantity range, and expiry date
|
- Added shared auth/session state with `localStorage` persistence.
|
||||||
- **Barcode Scanner** — Scan barcodes for inventory management (keyboard and hardware scanner support)
|
- Updated the dashboard to support login and live inventory summary data.
|
||||||
- **Data Visualization** — Pie chart showing inventory distribution
|
- Added a site-admin user creation form on the Admin page backed by `POST /api/auth/register`.
|
||||||
- **Responsive Design** — Works on desktop, tablet, and mobile devices
|
- Added a dedicated inventory management page for CRUD operations.
|
||||||
|
- Updated search to call the backend search endpoints.
|
||||||
|
- Updated barcode scanning so scans look up matching inventory items through the API.
|
||||||
|
- Removed the register panel and the old `Connected API` section from the login view.
|
||||||
|
|
||||||
## Project Structure
|
## Routes
|
||||||
|
|
||||||
### Pages
|
| Route | Purpose |
|
||||||
- `index.html` — Homepage with item component demo and inventory pie chart
|
| --- | --- |
|
||||||
- `search.html` — Search and filter inventory by name, location, quantity, and expiry date
|
| `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. |
|
||||||
- `barcode.html` — Barcode scanner with console logging for testing
|
| `/admin` | Site-admin page for household administration and user creation. |
|
||||||
- `pantry.html` — Pantry inventory container
|
| `/inventory` | Manage locations and inventory items. |
|
||||||
- `fridge.html` — Fridge inventory container
|
| `/search` | Search items and locations, then filter results further in the UI. |
|
||||||
- `freezer.html` — Freezer inventory container
|
| `/barcode` | Scan a barcode and search the inventory API for matches. |
|
||||||
|
| `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. |
|
||||||
|
|
||||||
### Core Modules
|
Routing is defined in `src/App.jsx`.
|
||||||
- `navbar.js` — Dynamic navigation bar system loaded by all pages
|
|
||||||
- `main.css` — Global styles and responsive design
|
|
||||||
- `item-component.js` — Reusable ES6 module for displaying inventory items in a grid
|
|
||||||
- `search.js` — Search and filtering logic with 20 sample inventory items including expiry dates
|
|
||||||
- `barcode-scanner.js` — Barcode capture and console logging module
|
|
||||||
- `piechart.js` — Inventory distribution pie chart visualization
|
|
||||||
|
|
||||||
### Documentation
|
## API Endpoint Map
|
||||||
- `ITEM_COMPONENT.md` — Detailed documentation for the item component factory
|
|
||||||
- `README.md` — This file
|
|
||||||
|
|
||||||
## Features Overview
|
### Dashboard (`src/pages/HomePage/HomePage.jsx`)
|
||||||
|
|
||||||
### 🏠 Homepage (`index.html`)
|
When signed out:
|
||||||
The landing page showcasing the project with:
|
|
||||||
- Interactive item component grid (3 sample items)
|
|
||||||
- Inventory breakdown pie chart showing distribution across storage locations
|
|
||||||
- Responsive grid layout that adapts to screen size
|
|
||||||
|
|
||||||
**Sample Data:**
|
- `POST /api/auth/login`
|
||||||
- Pantry: 104 items
|
|
||||||
- Fridge: 30 items
|
|
||||||
- Freezer: 87 items
|
|
||||||
|
|
||||||
### 🔍 Search Page (`search.html`)
|
When signed in:
|
||||||
Advanced inventory search and filtering interface.
|
|
||||||
|
|
||||||
**Features:**
|
- `GET /api/locations`
|
||||||
- Search by item name (case-insensitive)
|
- `GET /api/inventoryitems`
|
||||||
- Filter by storage location (All, Pantry, Fridge, Freezer)
|
|
||||||
- Filter by quantity range (min/max values)
|
|
||||||
- **NEW: Filter by expiry date range (start date and end date)**
|
|
||||||
- Real-time result display with item cards
|
|
||||||
- Visual expiry status indicators (Fresh, Expiring Soon, Expired)
|
|
||||||
- Reset button to clear all filters
|
|
||||||
- Keyboard support (press Enter to search)
|
|
||||||
- Date range validation (start date ≤ end date)
|
|
||||||
|
|
||||||
**Expiry Date Features:**
|
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`)
|
||||||
- Each inventory item includes an expiry date
|
|
||||||
- Visual badges show expiry status:
|
|
||||||
- 🟢 **Fresh** — Items expiring in more than 7 days
|
|
||||||
- 🟠 **Expiring Soon** — Items expiring within 7 days
|
|
||||||
- 🔴 **Expired** — Items past expiry date
|
|
||||||
- Formatted expiry dates displayed on item cards
|
|
||||||
- Filter by start and end date to find items expiring in a specific timeframe
|
|
||||||
|
|
||||||
**Sample Usage:**
|
Locations:
|
||||||
```
|
|
||||||
Search for items expiring between March 15 and April 15
|
- `GET /api/locations`
|
||||||
Results show all items with expiry dates in that range
|
- `POST /api/locations`
|
||||||
Items display formatted dates and expiry status badges
|
- `PUT /api/locations/{id}`
|
||||||
|
- `DELETE /api/locations/{id}`
|
||||||
|
|
||||||
|
Inventory items:
|
||||||
|
|
||||||
|
- `GET /api/inventoryitems`
|
||||||
|
- `GET /api/inventoryitems/{id}`
|
||||||
|
- `POST /api/inventoryitems`
|
||||||
|
- `PUT /api/inventoryitems/{id}`
|
||||||
|
- `DELETE /api/inventoryitems/{id}`
|
||||||
|
|
||||||
|
### Admin (`src/pages/AdminPage/AdminPage.jsx`)
|
||||||
|
|
||||||
|
Households:
|
||||||
|
|
||||||
|
- `GET /api/households`
|
||||||
|
- `POST /api/households`
|
||||||
|
- `PUT /api/households/{id}`
|
||||||
|
- `POST /api/households/{id}/invite`
|
||||||
|
- `DELETE /api/households/{id}/leave`
|
||||||
|
|
||||||
|
Users:
|
||||||
|
|
||||||
|
- `POST /api/auth/register`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The `/admin` route is only visible and accessible for users with the backend `Admin` role.
|
||||||
|
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
|
||||||
|
- The register endpoint does not assign roles, so new users are created without admin access by default.
|
||||||
|
|
||||||
|
### Search (`src/pages/SearchPage/SearchPage.jsx`)
|
||||||
|
|
||||||
|
Initial page data:
|
||||||
|
|
||||||
|
- `GET /api/locations`
|
||||||
|
- `GET /api/inventoryitems`
|
||||||
|
|
||||||
|
Search actions:
|
||||||
|
|
||||||
|
- `GET /api/search/items?q={query}`
|
||||||
|
- `GET /api/search/locations?q={query}`
|
||||||
|
|
||||||
|
Note:
|
||||||
|
|
||||||
|
- The backend search API only supports the `q` query parameter.
|
||||||
|
- Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns.
|
||||||
|
|
||||||
|
### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`)
|
||||||
|
|
||||||
|
- `GET /api/search/items?q={barcode}`
|
||||||
|
|
||||||
|
The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value.
|
||||||
|
|
||||||
|
### Users (`src/pages/UsersPage/UsersPage.jsx`)
|
||||||
|
|
||||||
|
- `GET /api/users`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The users page is intentionally minimal and is ready to consume a future users endpoint.
|
||||||
|
- The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`.
|
||||||
|
|
||||||
|
### Shared Auth Support (`src/api/client.js`)
|
||||||
|
|
||||||
|
The shared API client also supports:
|
||||||
|
|
||||||
|
- `POST /api/auth/register`
|
||||||
|
- `POST /api/auth/refresh-token`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `register` is exposed in the Admin page for site admins and does not replace the current signed-in session.
|
||||||
|
|
||||||
|
## API Configuration
|
||||||
|
|
||||||
|
The API base URL is configured in `src/api/client.js`.
|
||||||
|
|
||||||
|
Current setup:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000').trim().replace(/\/$/, '')
|
||||||
```
|
```
|
||||||
|
|
||||||
**Available Items (20 total across all locations):**
|
That means:
|
||||||
- Pantry: Pasta (120 days), Rice (180 days), Cereal (60 days), Flour (150 days), Sugar (200 days), Salt (365 days), Olive Oil (90 days), Canned Beans (EXPIRED -10 days)
|
|
||||||
- Fridge: Milk (5 days), Cheese (30 days), Greek Yogurt (7 days), Eggs (21 days), Butter (45 days), Chicken Salad (EXPIRED -2 days)
|
|
||||||
- Freezer: Ice Cream (90 days), Frozen Vegetables (180 days), Chicken Breast (120 days), Ground Beef (150 days), Pizza (200 days), Ice (365 days)
|
|
||||||
|
|
||||||
### 📱 Barcode Scanner (`barcode.html`)
|
- If `VITE_API_BASE_URL` is set, the frontend uses that value.
|
||||||
Barcode capture interface with console logging for testing.
|
- Otherwise it defaults to `http://localhost:5000` to match the C# API repo's `appsettings.json`.
|
||||||
|
|
||||||
**Features:**
|
### Recommended Override
|
||||||
- Keyboard input simulation (type barcode + press Enter)
|
|
||||||
- Hardware barcode scanner device support (ready for integration)
|
|
||||||
- Paste support (Ctrl/Cmd+V)
|
|
||||||
- Console logging with formatted output including:
|
|
||||||
- Timestamp (HH:MM:SS.mmm format)
|
|
||||||
- Barcode value
|
|
||||||
- Input type detection (keyboard, hardware-scanner, keyboard-paste)
|
|
||||||
- Metadata for debugging
|
|
||||||
- Auto-focus input field after each scan
|
|
||||||
- Visual status indicator
|
|
||||||
- Helper instructions and "Open Console" button
|
|
||||||
|
|
||||||
**Console Output Format:**
|
Create a `.env.local` file in the project root:
|
||||||
```
|
|
||||||
[Barcode Scanned] 14:07:32.456 | Barcode: 5901234123457 | Input: keyboard
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Testing Instructions:**
|
Example for a different backend host:
|
||||||
1. Navigate to the Barcode Scanner page via navbar
|
|
||||||
2. Type a barcode value or use a barcode scanner device
|
|
||||||
3. Press Enter to complete the scan
|
|
||||||
4. Press F12 to open Developer Tools
|
|
||||||
5. Switch to the Console tab to view scanned barcodes with metadata
|
|
||||||
|
|
||||||
### 📦 Storage Location Pages
|
```env
|
||||||
- `pantry.html` — Pantry inventory (expandable for displaying specific items)
|
VITE_API_BASE_URL=https://your-api-host.example.com
|
||||||
- `fridge.html` — Fridge inventory
|
```
|
||||||
- `freezer.html` — Freezer inventory
|
|
||||||
|
|
||||||
These pages are currently placeholder containers ready for future development (e.g., displaying items specific to each location).
|
After changing the env file, restart the Vite dev server.
|
||||||
|
|
||||||
## Quick Start
|
## Auth and Session Behavior
|
||||||
|
|
||||||
The project is a static site. For best results, serve it over HTTP (ES module imports can be blocked when opened via the `file://` protocol in some browsers).
|
Auth/session logic lives in:
|
||||||
|
|
||||||
### Python 3 (Recommended)
|
- `src/api/client.js`
|
||||||
|
- `src/context/AuthContext.jsx`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Access token and refresh token are stored in `localStorage`.
|
||||||
|
- Storage key: `pantry-management-session`
|
||||||
|
- Authenticated requests automatically send `Authorization: Bearer <token>`.
|
||||||
|
- If a request returns `401`, the client attempts `POST /api/auth/refresh-token` once and retries the original request.
|
||||||
|
- Logout clears local session state even if the API logout request fails.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### 1. Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the project root
|
npm install
|
||||||
python -m http.server 8000
|
|
||||||
|
|
||||||
# Then open http://localhost:8000 in your browser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative Options
|
### 2. Make sure the backend API is running
|
||||||
|
|
||||||
- **VS Code Live Server Extension** — Right-click `index.html` → "Open with Live Server"
|
Default expected API URL:
|
||||||
- **Node.js http-server** — `npx http-server`
|
|
||||||
- **Any static file server**
|
|
||||||
|
|
||||||
## Usage
|
```text
|
||||||
|
http://localhost:5000
|
||||||
### Navigation
|
|
||||||
The navbar appears at the top of every page and provides links to:
|
|
||||||
- Homepage — Main demo page with pie chart
|
|
||||||
- Search — Advanced inventory search and filtering with expiry date support
|
|
||||||
- Barcode Scanner — Barcode capture interface (testing via console)
|
|
||||||
- Pantry, Fridge, Freezer — Individual storage location pages
|
|
||||||
|
|
||||||
### Using the Search Page
|
|
||||||
1. Open the Search page from the navbar
|
|
||||||
2. Enter search criteria:
|
|
||||||
- Item name (optional)
|
|
||||||
- Storage location (optional, defaults to "All")
|
|
||||||
- Quantity range (optional, defaults to 0-999)
|
|
||||||
- **Expiry date range (optional) — Leave blank to ignore**
|
|
||||||
3. Click "Search" or press Enter
|
|
||||||
4. Results display as item cards using the item component with expiry status badges
|
|
||||||
5. Click "Reset" to clear all filters
|
|
||||||
|
|
||||||
**Expiry Date Search Examples:**
|
|
||||||
- Find items expiring soon: Set start date to today, end date to 7 days from today
|
|
||||||
- Find expired items: Set start date to 100 days ago, end date to today
|
|
||||||
- Find fresh items: Set start date to tomorrow, end date to 90 days from today
|
|
||||||
|
|
||||||
### Using the Barcode Scanner
|
|
||||||
1. Open the Barcode Scanner page from the navbar
|
|
||||||
2. Click in the input field (auto-focused)
|
|
||||||
3. Enter a barcode:
|
|
||||||
- Type manually and press Enter
|
|
||||||
- Scan with a barcode scanner device
|
|
||||||
- Paste a value (Ctrl/Cmd+V)
|
|
||||||
4. Press F12 to open Developer Tools Console
|
|
||||||
5. View scanned barcodes in the Console tab with timestamp and metadata
|
|
||||||
|
|
||||||
### The Item Component
|
|
||||||
The `item-component` is a reusable UI building block used throughout the app. See `ITEM_COMPONENT.md` for detailed documentation on using it in your own pages.
|
|
||||||
|
|
||||||
**Quick Example:**
|
|
||||||
```html
|
|
||||||
<script type="module">
|
|
||||||
import createItemComponent from './item-component.js';
|
|
||||||
|
|
||||||
const item = createItemComponent({
|
|
||||||
imgSrc: 'https://picsum.photos/seed/1/200/200',
|
|
||||||
imgAlt: 'Milk',
|
|
||||||
text1: 'Milk',
|
|
||||||
text2: 'Fridge — 1 carton',
|
|
||||||
text3: 'Expires Mar 15, 2026'
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('container').appendChild(item);
|
|
||||||
</script>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Customizing and Extending
|
If you use a different URL, set `VITE_API_BASE_URL` in `.env.local`.
|
||||||
|
|
||||||
### Adding New Items to Search
|
### 3. Start the frontend
|
||||||
Edit `search.js` and add items to the `inventoryData` array:
|
|
||||||
```javascript
|
```bash
|
||||||
export const inventoryData = [
|
npm run dev
|
||||||
{
|
|
||||||
id: 21,
|
|
||||||
name: 'Coffee',
|
|
||||||
location: 'Pantry',
|
|
||||||
quantity: 2,
|
|
||||||
unit: 'bags',
|
|
||||||
expiryDate: '2026-06-15',
|
|
||||||
img: 'https://picsum.photos/seed/coffee/200/200'
|
|
||||||
},
|
|
||||||
// ... more items
|
|
||||||
];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Date Format:** Use ISO 8601 format (YYYY-MM-DD) for expiry dates.
|
### 4. Build for production
|
||||||
|
|
||||||
### Styling
|
```bash
|
||||||
- Override styles in `main.css` for global changes
|
npm run build
|
||||||
- Page-specific styles are included in `<style>` tags within each HTML file
|
|
||||||
- The item component injects responsive grid CSS automatically
|
|
||||||
- Expiry badge styles can be customized via `.expiry-fresh`, `.expiry-soon`, `.expiry-expired` classes
|
|
||||||
|
|
||||||
### Creating New Pages
|
|
||||||
1. Create a new `.html` file following the template:
|
|
||||||
```html
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Page Title</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Page Title</h2>
|
|
||||||
<!-- Your content here -->
|
|
||||||
</div>
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Update `navbar.js` to add a link to your new page:
|
### 5. Preview the production build
|
||||||
```javascript
|
|
||||||
<li><a href="yourpage.html" data-route="yourpage.html">Your Page</a></li>
|
```bash
|
||||||
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
### Converting to Production
|
## Using the App
|
||||||
- Replace placeholder images with real inventory photos
|
|
||||||
- Implement backend storage (currently using static data)
|
|
||||||
- Add user authentication
|
|
||||||
- Integrate with real barcode/UPC database
|
|
||||||
- Consider a frontend framework (React, Vue, etc.) for scale
|
|
||||||
- Add unit/integration tests
|
|
||||||
- Set up CI/CD pipeline
|
|
||||||
- Implement database for persistent storage and expiry date tracking
|
|
||||||
|
|
||||||
## Contributing
|
### Login
|
||||||
|
|
||||||
Contributions are welcome! Suggested improvements:
|
1. Open `/`
|
||||||
|
2. Enter an existing user email and password
|
||||||
|
3. Sign in to unlock the protected routes and API-backed data
|
||||||
|
|
||||||
- [ ] Add unit and visual tests
|
There is no public registration form in the signed-out UI. Site admins can create accounts from `/admin`.
|
||||||
- [ ] Replace placeholder images with real inventory photos
|
|
||||||
- [ ] Implement barcode scanner backend integration (save to database)
|
|
||||||
- [ ] Add user accounts and authentication
|
|
||||||
- [ ] Implement item editing/deletion on storage pages
|
|
||||||
- [ ] Add barcode/UPC lookup for real products
|
|
||||||
- [ ] Convert item-component to custom element (`<item-component>`)
|
|
||||||
- [ ] Add more detailed inventory tracking (expiration dates calculations, locations within rooms, batch numbers, etc.)
|
|
||||||
- [ ] Add email/notification alerts for items expiring soon
|
|
||||||
- [ ] Implement CSV import/export for inventory
|
|
||||||
|
|
||||||
## License
|
### Inventory Page
|
||||||
|
|
||||||
Use as you like; no license file is included by default.
|
Use `/inventory` to:
|
||||||
|
|
||||||
|
- Create, edit, and delete locations
|
||||||
|
- Create, edit, and delete inventory items
|
||||||
|
- Load a single inventory item before editing
|
||||||
|
|
||||||
|
Important update behavior:
|
||||||
|
|
||||||
|
- Blank text fields on item update are sent as empty strings.
|
||||||
|
- Blank amount, date, and location fields are omitted from the update payload because the backend only updates fields that are provided.
|
||||||
|
|
||||||
|
### Admin Page
|
||||||
|
|
||||||
|
Use `/admin` to:
|
||||||
|
|
||||||
|
- Review household data as a site admin
|
||||||
|
- Create a new household
|
||||||
|
- Invite members by email to the selected household
|
||||||
|
- Leave a household from the same page
|
||||||
|
- Create a new user account through the register endpoint
|
||||||
|
|
||||||
|
### Search Page
|
||||||
|
|
||||||
|
Use `/search` to:
|
||||||
|
|
||||||
|
- Search items with the backend `items` search endpoint
|
||||||
|
- Search locations with the backend `locations` search endpoint
|
||||||
|
- Narrow item results further by location, amount, and expiry date in the frontend
|
||||||
|
- Page through large result sets
|
||||||
|
|
||||||
|
### Barcode Page
|
||||||
|
|
||||||
|
Use `/barcode` to:
|
||||||
|
|
||||||
|
- Scan via keyboard input
|
||||||
|
- Scan via device camera
|
||||||
|
- Automatically search inventory after a scan
|
||||||
|
- Re-run search for the last scanned barcode
|
||||||
|
|
||||||
|
Camera scanning works best on HTTPS or localhost with a trusted dev certificate.
|
||||||
|
|
||||||
|
### Users Page
|
||||||
|
|
||||||
|
Use `/users` to:
|
||||||
|
|
||||||
|
- Refresh the users dataset from the backend once `GET /api/users` exists
|
||||||
|
- Verify a future users endpoint without adding UI complexity first
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
|
||||||
|
| `src/context/AuthContext.jsx` | Shared auth/session state for the React app |
|
||||||
|
| `src/App.jsx` | Route definitions |
|
||||||
|
| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI |
|
||||||
|
| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI |
|
||||||
|
| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI |
|
||||||
|
| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI |
|
||||||
|
| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI |
|
||||||
|
| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### The frontend cannot reach the API
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
- The backend is running
|
||||||
|
- `VITE_API_BASE_URL` is correct
|
||||||
|
- The backend HTTPS certificate is trusted
|
||||||
|
- CORS is enabled on the API
|
||||||
|
|
||||||
|
### Login works but protected pages fail later
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
- The backend refresh token flow is working
|
||||||
|
- The stored session in browser `localStorage` is valid
|
||||||
|
- The API still matches the endpoint shapes described in the backend README
|
||||||
|
|
||||||
|
### The users page shows a placeholder instead of data
|
||||||
|
|
||||||
|
This is expected until the backend adds a users endpoint.
|
||||||
|
|
||||||
|
- The page assumes `GET /api/users`
|
||||||
|
- The current C# repo does not expose that route yet
|
||||||
|
- Once the backend endpoint exists, the page will start rendering results without additional routing work
|
||||||
|
|
||||||
|
### Search filters do not match backend behavior exactly
|
||||||
|
|
||||||
|
This is expected for some filters.
|
||||||
|
|
||||||
|
- The backend only exposes `q` search for items and locations.
|
||||||
|
- Additional amount, location, and expiry filters are applied in the frontend after the API returns results.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The barcode page uses `@ericblade/quagga2` for camera scanning.
|
||||||
|
- The frontend was verified with `npm run build` after these API integration changes.
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
// barcode-scanner.js - Barcode scanning module with console logging for testing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize barcode scanner with dual input support:
|
|
||||||
* - Keyboard input (simulation)
|
|
||||||
* - Hardware barcode scanner devices
|
|
||||||
*
|
|
||||||
* Logs scanned barcodes to console for testing purposes
|
|
||||||
*/
|
|
||||||
|
|
||||||
let barcodeBuffer = '';
|
|
||||||
let lastBarcodeTime = 0;
|
|
||||||
const SCANNER_TIMEOUT = 100; // ms - detect hardware scanner vs human typing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log barcode to console with metadata
|
|
||||||
* @param {string} barcode - The scanned barcode value
|
|
||||||
* @param {string} inputType - Either 'keyboard' or 'hardware-scanner'
|
|
||||||
*/
|
|
||||||
export function logBarcodeToConsole(barcode, inputType = 'keyboard') {
|
|
||||||
const timestamp = new Date().toLocaleTimeString('en-US', {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
fractionalSecondDigits: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`%c[Barcode Scanned] %c${timestamp} | %cBarcode: %c${barcode} %c| Input: %c${inputType}`,
|
|
||||||
'color: #4CAF50; font-weight: bold;',
|
|
||||||
'color: #666; font-family: monospace;',
|
|
||||||
'color: #333; font-weight: bold;',
|
|
||||||
'color: #2196F3; font-weight: bold; font-family: monospace;',
|
|
||||||
'color: #666;',
|
|
||||||
'color: #FF9800; font-weight: bold;'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Additional metadata for debugging
|
|
||||||
console.log({
|
|
||||||
barcode,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
inputType,
|
|
||||||
length: barcode.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if input is from a hardware scanner or keyboard
|
|
||||||
* Hardware scanners typically input a full barcode very quickly (within SCANNER_TIMEOUT ms)
|
|
||||||
* @returns {string} - 'hardware-scanner' or 'keyboard'
|
|
||||||
*/
|
|
||||||
function detectInputType(currentTime) {
|
|
||||||
const timeSinceLastInput = currentTime - lastBarcodeTime;
|
|
||||||
lastBarcodeTime = currentTime;
|
|
||||||
|
|
||||||
// If more than SCANNER_TIMEOUT ms since last character, it's likely keyboard input
|
|
||||||
return timeSinceLastInput > SCANNER_TIMEOUT ? 'keyboard' : 'hardware-scanner';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle barcode input from both keyboard and hardware scanner
|
|
||||||
* @param {HTMLElement} inputElement - The input field element
|
|
||||||
* @param {Function} onBarcodeScanned - Callback function when barcode is complete
|
|
||||||
*/
|
|
||||||
export function initializeBarcodeScanner(inputElement, onBarcodeScanned) {
|
|
||||||
if (!inputElement) {
|
|
||||||
console.error('Barcode input element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-focus for seamless scanning
|
|
||||||
inputElement.focus();
|
|
||||||
|
|
||||||
inputElement.addEventListener('keypress', (event) => {
|
|
||||||
// Most barcode scanners send Enter key at the end
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const barcode = inputElement.value.trim();
|
|
||||||
|
|
||||||
if (barcode) {
|
|
||||||
// Detect input type
|
|
||||||
const inputType = barcodeBuffer.length === 0 ? 'keyboard' : 'hardware-scanner';
|
|
||||||
barcodeBuffer = '';
|
|
||||||
|
|
||||||
// Log to console
|
|
||||||
logBarcodeToConsole(barcode, inputType);
|
|
||||||
|
|
||||||
// Call the callback function
|
|
||||||
if (onBarcodeScanned) {
|
|
||||||
onBarcodeScanned(barcode, inputType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear input for next scan
|
|
||||||
inputElement.value = '';
|
|
||||||
inputElement.focus();
|
|
||||||
} else {
|
|
||||||
// Accumulate characters in buffer for input type detection
|
|
||||||
const now = Date.now();
|
|
||||||
if (barcodeBuffer === '') {
|
|
||||||
lastBarcodeTime = now;
|
|
||||||
}
|
|
||||||
barcodeBuffer += event.key;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle pasted input (in case user pastes barcode)
|
|
||||||
inputElement.addEventListener('paste', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const pastedText = (event.clipboardData || window.clipboardData).getData('text').trim();
|
|
||||||
|
|
||||||
if (pastedText) {
|
|
||||||
logBarcodeToConsole(pastedText, 'keyboard-paste');
|
|
||||||
|
|
||||||
if (onBarcodeScanned) {
|
|
||||||
onBarcodeScanned(pastedText, 'keyboard-paste');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inputElement.value = '';
|
|
||||||
inputElement.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Maintain focus
|
|
||||||
inputElement.addEventListener('blur', () => {
|
|
||||||
setTimeout(() => inputElement.focus(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('%cBarcode Scanner Initialized', 'color: #4CAF50; font-weight: bold;');
|
|
||||||
console.log('Ready to scan. Focus the input field and scan a barcode or press Enter to complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get scanning statistics from console (for testing)
|
|
||||||
* This is just a helper for development/testing
|
|
||||||
* @returns {Object} - Statistics object
|
|
||||||
*/
|
|
||||||
export function getScanningStats() {
|
|
||||||
return {
|
|
||||||
initialized: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
message: 'Barcode scanner is active. Check console for scanned barcodes.'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
288
barcode.html
288
barcode.html
@@ -1,288 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Barcode Scanner</title>
|
|
||||||
<style>
|
|
||||||
.scanner-container {
|
|
||||||
background: #f9f9f9;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 30px auto;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-header h3 {
|
|
||||||
color: #333;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-header p {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 10px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-input-wrapper {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-input-wrapper label {
|
|
||||||
display: block;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4CAF50;
|
|
||||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
|
||||||
background-color: #fffef0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-status {
|
|
||||||
text-align: center;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
border: 1px solid #c8e6c9;
|
|
||||||
color: #2e7d32;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-status.active {
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-color: #c8e6c9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-info {
|
|
||||||
background-color: #e3f2fd;
|
|
||||||
border: 1px solid #bbdefb;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #1565c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-info h4 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #0d47a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-info ul {
|
|
||||||
margin: 10px 0 0 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanner-info li {
|
|
||||||
margin: 5px 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hint {
|
|
||||||
background-color: #fff3e0;
|
|
||||||
border: 1px solid #ffe0b2;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 15px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #e65100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-hint strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #d84315;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #ddd;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-console {
|
|
||||||
background-color: #2196F3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-console:hover {
|
|
||||||
background-color: #0b7dda;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.scanner-container {
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.barcode-input {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Barcode Scanner</h2>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="scanner-container">
|
|
||||||
<div class="scanner-header">
|
|
||||||
<h3>📱 Barcode Scanner</h3>
|
|
||||||
<p>Scan a barcode or manually enter one below</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scanner-status active" id="scanner-status">
|
|
||||||
✓ Scanner Ready - Click below and scan a barcode
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="barcode-input-wrapper">
|
|
||||||
<label for="barcode-input">Barcode Input:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="barcode-input"
|
|
||||||
class="barcode-input"
|
|
||||||
placeholder="Scan or type barcode here..."
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="scanner-info">
|
|
||||||
<h4>How to Use:</h4>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Keyboard Entry:</strong> Type or paste a barcode and press Enter</li>
|
|
||||||
<li><strong>Hardware Scanner:</strong> Plug in a barcode scanner and scan directly</li>
|
|
||||||
<li><strong>Testing:</strong> Scanned barcodes are logged to the browser console (Press F12)</li>
|
|
||||||
<li><strong>Input Focus:</strong> The input field automatically refocuses after each scan</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="console-hint">
|
|
||||||
<strong>🔍 View Console Output:</strong>
|
|
||||||
Press F12 (or Cmd+Option+I on Mac) to open Developer Tools. Check the Console tab to see scanned barcodes logged with timestamps and input type detection.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<button type="button" class="btn open-console" id="open-console-btn">
|
|
||||||
📟 Open Console (F12)
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="clear-input-btn">
|
|
||||||
🗑️ Clear Input
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
<script type="module">
|
|
||||||
import { initializeBarcodeScanner, logBarcodeToConsole } from './barcode-scanner.js';
|
|
||||||
|
|
||||||
const barcodeInput = document.getElementById('barcode-input');
|
|
||||||
const scannerStatus = document.getElementById('scanner-status');
|
|
||||||
const openConsoleBtn = document.getElementById('open-console-btn');
|
|
||||||
const clearInputBtn = document.getElementById('clear-input-btn');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback when a barcode is scanned
|
|
||||||
* @param {string} barcode - The scanned barcode value
|
|
||||||
* @param {string} inputType - The type of input device
|
|
||||||
*/
|
|
||||||
function onBarcodeScanned(barcode, inputType) {
|
|
||||||
// Update status temporarily
|
|
||||||
const originalStatus = scannerStatus.textContent;
|
|
||||||
scannerStatus.textContent = `✓ Barcode scanned: ${barcode}`;
|
|
||||||
scannerStatus.style.backgroundColor = '#a5d6a7';
|
|
||||||
|
|
||||||
// Reset after 2 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
scannerStatus.textContent = originalStatus;
|
|
||||||
scannerStatus.style.backgroundColor = '';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the barcode scanner
|
|
||||||
initializeBarcodeScanner(barcodeInput, onBarcodeScanned);
|
|
||||||
|
|
||||||
// Clear input button
|
|
||||||
clearInputBtn.addEventListener('click', () => {
|
|
||||||
barcodeInput.value = '';
|
|
||||||
barcodeInput.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open console button - opens DevTools
|
|
||||||
openConsoleBtn.addEventListener('click', () => {
|
|
||||||
console.log('%c📊 Barcode Scanner Console\n\nUse this console to view scanned barcodes and their details.\nStart scanning to see logs appear here.', 'color: #4CAF50; font-size: 14px; font-weight: bold; line-height: 1.8;');
|
|
||||||
// In a real app, we can't programmatically open DevTools for security reasons
|
|
||||||
// But we can log a helpful message and suggest the user press F12
|
|
||||||
alert('Press F12 (or Cmd+Option+I on Mac) to open the Developer Tools Console.\n\nScanned barcodes will appear in the Console tab.');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log initial message
|
|
||||||
console.log('%c🎯 Barcode Scanner Ready', 'color: #4CAF50; font-weight: bold; font-size: 16px;');
|
|
||||||
console.log('Scan a barcode to see it logged here with timestamp and input type detection.');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
16
freezer.html
16
freezer.html
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Freezer</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Freezer Inventory</h2>
|
|
||||||
</div>
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
16
fridge.html
16
fridge.html
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Fridge</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Fridge Inventory</h2>
|
|
||||||
</div>
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
41
index.html
41
index.html
@@ -3,45 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="main.css">
|
<title>Pantry Manager</title>
|
||||||
<title>Homepage</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="navbar-placeholder"></div>
|
<div id="root"></div>
|
||||||
<div id="page-content">
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
<h2>Homepage</h2>
|
|
||||||
<hr>
|
|
||||||
<h3>To use</h3>
|
|
||||||
<div id="item-component-demo"></div>
|
|
||||||
<script type="module">
|
|
||||||
console.log('Demo script running');
|
|
||||||
import createItemComponent from './item-component.js';
|
|
||||||
const demoRoot = document.getElementById('item-component-demo');
|
|
||||||
demoRoot.className = 'item-component-grid';
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{ imgSrc: 'https://picsum.photos/seed/1/200/200', imgAlt: 'Chicken Salad', text1: 'Chicken Salad', text2: 'Fresh — 2 left', text3: 'Use within 3 days' },
|
|
||||||
{ imgSrc: 'https://picsum.photos/seed/2/200/200', imgAlt: 'Yogurt', text1: 'Greek Yogurt', text2: 'Chilled — 6 left', text3: 'Best before 5 days' },
|
|
||||||
{ imgSrc: 'https://picsum.photos/seed/3/200/200', imgAlt: 'Apples', text1: 'Red Apples', text2: 'Room temp — 12 left', text3: 'Keep away from moisture' }, ];
|
|
||||||
|
|
||||||
items.forEach(data => {
|
|
||||||
const comp = createItemComponent({ ...data, editable: false });
|
|
||||||
demoRoot.appendChild(comp);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<hr>
|
|
||||||
<h3>Inventory Breakdown</h3>
|
|
||||||
<div class="chart-container">
|
|
||||||
<svg width="350" height="350" viewBox="0 0 42 42">
|
|
||||||
<circle cx="21" cy="21" r="10" fill="transparent" stroke="#f2f2f2" stroke-width="20"></circle>
|
|
||||||
<circle id="seg-1" cx="21" cy="21" r="10" fill="transparent" stroke-width="20"></circle>
|
|
||||||
<circle id="seg-2" cx="21" cy="21" r="10" fill="transparent" stroke-width="20"></circle>
|
|
||||||
<circle id="seg-3" cx="21" cy="21" r="10" fill="transparent" stroke-width="20"></circle>
|
|
||||||
</svg>
|
|
||||||
<div id="legend"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
<script src="piechart.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
// item-component.js
|
|
||||||
// Reusable item component: image on left, three text areas on right.
|
|
||||||
|
|
||||||
const _ensureStyles = (() => {
|
|
||||||
if (document.getElementById('item-component-styles')) return true;
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'item-component-styles';
|
|
||||||
style.textContent = `
|
|
||||||
/* Grid wrapper for multiple item components */
|
|
||||||
.item-component-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:start}
|
|
||||||
.item-component{display:flex;align-items:flex-start;gap:12px;font-family:inherit;padding:10px;border:1px solid #e6e6e6;border-radius:8px;background:#fff}
|
|
||||||
.item-component__img{flex:0 0 auto}
|
|
||||||
.item-component__img img{display:block;width:72px;height:72px;object-fit:cover;border-radius:6px}
|
|
||||||
.item-component__content{flex:1;display:flex;flex-direction:column;gap:6px}
|
|
||||||
.item-component__line{margin:0;padding:0;color:#222}
|
|
||||||
.item-component__line--title{font-weight:700}
|
|
||||||
.item-component__line--subtitle{color:#555;font-size:0.95em}
|
|
||||||
.item-component__line--desc{color:#666;font-size:0.9em}
|
|
||||||
.item-component__textarea{width:100%;box-sizing:border-box;padding:6px;font:inherit;border:1px solid #ccc;border-radius:4px}
|
|
||||||
|
|
||||||
/* Responsive: on small screens show 1 or 2 columns */
|
|
||||||
@media (max-width: 900px){
|
|
||||||
.item-component-grid{grid-template-columns:repeat(2,1fr)}
|
|
||||||
}
|
|
||||||
@media (max-width: 560px){
|
|
||||||
.item-component-grid{grid-template-columns:repeat(1,1fr)}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
return true;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an item component element.
|
|
||||||
* @param {Object} opts
|
|
||||||
* @param {string} opts.imgSrc - image URL
|
|
||||||
* @param {string} opts.imgAlt - image alt text
|
|
||||||
* @param {string} opts.text1 - first text area (title)
|
|
||||||
* @param {string} opts.text2 - second text area (subtitle)
|
|
||||||
* @param {string} opts.text3 - third text area (description)
|
|
||||||
* @param {boolean} opts.editable - if true, text areas are `<textarea>` elements for editing
|
|
||||||
* @param {number} opts.imgWidth - image width in px
|
|
||||||
* @param {number} opts.imgHeight - image height in px
|
|
||||||
* @returns {HTMLElement}
|
|
||||||
*/
|
|
||||||
export function createItemComponent({imgSrc = '', imgAlt = '', text1 = '', text2 = '', text3 = '', editable = false, imgWidth = 80, imgHeight = 80} = {}) {
|
|
||||||
_ensureStyles;
|
|
||||||
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.className = 'item-component';
|
|
||||||
|
|
||||||
// Image container
|
|
||||||
const imgWrap = document.createElement('div');
|
|
||||||
imgWrap.className = 'item-component__img';
|
|
||||||
const img = document.createElement('img');
|
|
||||||
if (imgSrc) img.src = imgSrc;
|
|
||||||
img.alt = imgAlt || '';
|
|
||||||
img.width = imgWidth;
|
|
||||||
img.height = imgHeight;
|
|
||||||
imgWrap.appendChild(img);
|
|
||||||
root.appendChild(imgWrap);
|
|
||||||
|
|
||||||
// Content container with three text areas
|
|
||||||
const content = document.createElement('div');
|
|
||||||
content.className = 'item-component__content';
|
|
||||||
|
|
||||||
function makeTextNode(text, cls) {
|
|
||||||
if (editable) {
|
|
||||||
const ta = document.createElement('textarea');
|
|
||||||
ta.className = 'item-component__textarea ' + cls;
|
|
||||||
ta.value = text;
|
|
||||||
return ta;
|
|
||||||
}
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'item-component__line ' + cls;
|
|
||||||
el.textContent = text;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
const line1 = makeTextNode(text1, 'item-component__line--title');
|
|
||||||
const line2 = makeTextNode(text2, 'item-component__line--subtitle');
|
|
||||||
const line3 = makeTextNode(text3, 'item-component__line--desc');
|
|
||||||
|
|
||||||
content.appendChild(line1);
|
|
||||||
content.appendChild(line2);
|
|
||||||
content.appendChild(line3);
|
|
||||||
root.appendChild(content);
|
|
||||||
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createItemComponent;
|
|
||||||
105
main.css
105
main.css
@@ -1,105 +0,0 @@
|
|||||||
/* Dynamically Changing the content padding depending on the width of the screen */
|
|
||||||
@media screen and (min-width: 1921px) and (max-width: 2560px) {
|
|
||||||
body {
|
|
||||||
padding-left: 20%;
|
|
||||||
padding-right: 20%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 1920px) {
|
|
||||||
body {
|
|
||||||
padding-left: 10%;
|
|
||||||
padding-right: 10%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General Styling */
|
|
||||||
body{
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: xx-large;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: x-large;
|
|
||||||
}
|
|
||||||
hr {
|
|
||||||
border: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #5b5b5b;
|
|
||||||
margin: 20px 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
/* Navbar Styling */
|
|
||||||
nav ul {
|
|
||||||
list-style-type: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #333333;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Give navbar the same rounded corners as the item components and clip children */
|
|
||||||
nav ul {
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li a {
|
|
||||||
display: block;
|
|
||||||
color: white;
|
|
||||||
padding: 14px 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul li a:hover {
|
|
||||||
background-color: #111111;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pie chart styling */
|
|
||||||
.chart-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: sans-serif;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pie-segment {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transform-origin: center;
|
|
||||||
transition: stroke-dasharray 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
circle {
|
|
||||||
transition: stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease;
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#legend {
|
|
||||||
font-family: sans-serif;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
16
navbar.js
16
navbar.js
@@ -1,16 +0,0 @@
|
|||||||
function createNavbar() {
|
|
||||||
const navbarHTML = `
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="index.html" data-route="index.html">Homepage</a></li>
|
|
||||||
<li><a href="search.html" data-route="search.html">Search</a></li>
|
|
||||||
<li><a href="barcode.html" data-route="barcode.html">Barcode Scanner</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
`;
|
|
||||||
const navbarContainer = document.getElementById('navbar-placeholder');
|
|
||||||
if (navbarContainer) {
|
|
||||||
navbarContainer.innerHTML = navbarHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createNavbar();
|
|
||||||
1687
package-lock.json
generated
Normal file
1687
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "webpage-playground",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.betteridge.net/ldbetteridge/pantry-management-frontend.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@ericblade/quagga2": "^1.12.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"vite": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pantry.html
16
pantry.html
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Pantry</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Pantry Inventory</h2>
|
|
||||||
</div>
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
48
piechart.js
48
piechart.js
@@ -1,48 +0,0 @@
|
|||||||
const segments = [
|
|
||||||
{ label: "Pantry", value: 104, color: getRandomColor() },
|
|
||||||
{ label: "Fridge", value: 30, color: getRandomColor() },
|
|
||||||
{ label: "Freezer", value: 87, color: getRandomColor() }
|
|
||||||
];
|
|
||||||
|
|
||||||
const totalValue = segments.reduce((accumulator, current) => {
|
|
||||||
return accumulator + current.value;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const circumference = 2 * Math.PI * 10;
|
|
||||||
|
|
||||||
function renderPieChart(data) {
|
|
||||||
let currentOffset = 0;
|
|
||||||
|
|
||||||
data.forEach((item, index) => {
|
|
||||||
const fraction = item.value / totalValue;
|
|
||||||
const segmentLength = fraction * circumference;
|
|
||||||
const circle = document.getElementById(`seg-${index + 1}`);
|
|
||||||
circle.style.strokeDasharray = `${segmentLength} ${circumference}`;
|
|
||||||
circle.style.strokeDashoffset = -currentOffset;
|
|
||||||
circle.style.stroke = item.color;
|
|
||||||
currentOffset += segmentLength;
|
|
||||||
addLegendItem(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLegendItem(item) {
|
|
||||||
const legend = document.getElementById('legend');
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'legend-item';
|
|
||||||
div.innerHTML = `
|
|
||||||
<span class="dot" style="background:${item.color}"></span>
|
|
||||||
<span>${item.label}: <strong>${item.value}</strong></span>
|
|
||||||
`;
|
|
||||||
legend.appendChild(div);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRandomColor() {
|
|
||||||
const letters = '0123456789ABCDEF';
|
|
||||||
let color = '#';
|
|
||||||
for (let i = 0; i < 6; i++) {
|
|
||||||
color += letters[Math.floor(Math.random() * 10)];
|
|
||||||
}
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderPieChart(segments);
|
|
||||||
328
search.html
328
search.html
@@ -1,328 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="stylesheet" href="main.css">
|
|
||||||
<title>Search Inventory</title>
|
|
||||||
<style>
|
|
||||||
.search-container {
|
|
||||||
background: #f9f9f9;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #4CAF50;
|
|
||||||
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-range,
|
|
||||||
.date-range {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quantity-range .form-group,
|
|
||||||
.date-range .form-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #ddd;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: #bbb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-info {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-expiry-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-fresh {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-soon {
|
|
||||||
background-color: #fff3e0;
|
|
||||||
color: #e65100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-expired {
|
|
||||||
background-color: #ffebee;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="navbar-placeholder"></div>
|
|
||||||
<div id="page-content">
|
|
||||||
<h2>Search Inventory</h2>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="search-container">
|
|
||||||
<form class="search-form" id="search-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="search-name">Item Name:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="search-name"
|
|
||||||
placeholder="e.g., Milk, Pasta, Chicken..."
|
|
||||||
autocomplete="off"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="search-location">Storage Location:</label>
|
|
||||||
<select id="search-location">
|
|
||||||
<!-- Options populated by script -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Quantity Range:</label>
|
|
||||||
<div class="quantity-range">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="min-quantity" style="font-size: 12px;">Min:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="min-quantity"
|
|
||||||
min="0"
|
|
||||||
value="0"
|
|
||||||
placeholder="0"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="max-quantity" style="font-size: 12px;">Max:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="max-quantity"
|
|
||||||
min="0"
|
|
||||||
value="999"
|
|
||||||
placeholder="999"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Expiry Date Range:</label>
|
|
||||||
<div class="date-range">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="min-expiry-date" style="font-size: 12px;">Start Date:</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="min-expiry-date"
|
|
||||||
placeholder="Start date"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="max-expiry-date" style="font-size: 12px;">End Date:</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="max-expiry-date"
|
|
||||||
placeholder="End date"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-buttons">
|
|
||||||
<button type="button" class="btn btn-primary" id="search-btn">Search</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="reset-btn">Reset</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="results-container">
|
|
||||||
<div class="results-info" id="results-info"></div>
|
|
||||||
<div id="results-grid"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="navbar.js"></script>
|
|
||||||
<script type="module">
|
|
||||||
import { searchInventory, getLocations, formatDate, getExpiryStatus } from './search.js';
|
|
||||||
import createItemComponent from './item-component.js';
|
|
||||||
|
|
||||||
const searchForm = document.getElementById('search-form');
|
|
||||||
const searchNameInput = document.getElementById('search-name');
|
|
||||||
const locationSelect = document.getElementById('search-location');
|
|
||||||
const minQuantityInput = document.getElementById('min-quantity');
|
|
||||||
const maxQuantityInput = document.getElementById('max-quantity');
|
|
||||||
const minExpiryDateInput = document.getElementById('min-expiry-date');
|
|
||||||
const maxExpiryDateInput = document.getElementById('max-expiry-date');
|
|
||||||
const searchBtn = document.getElementById('search-btn');
|
|
||||||
const resetBtn = document.getElementById('reset-btn');
|
|
||||||
const resultsGrid = document.getElementById('results-grid');
|
|
||||||
const resultsInfo = document.getElementById('results-info');
|
|
||||||
|
|
||||||
// Populate location dropdown
|
|
||||||
function populateLocations() {
|
|
||||||
const locations = getLocations();
|
|
||||||
locationSelect.innerHTML = locations
|
|
||||||
.map(loc => `<option value="${loc}">${loc}</option>`)
|
|
||||||
.join('');
|
|
||||||
locationSelect.value = 'All';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get expiry status badge HTML
|
|
||||||
function getExpiryBadgeHtml(expiryDate) {
|
|
||||||
const status = getExpiryStatus(expiryDate);
|
|
||||||
const badgeClass = status.status === 'Expired' ? 'expiry-expired' :
|
|
||||||
status.status === 'Soon' || status.status === 'Today' ? 'expiry-soon' : 'expiry-fresh';
|
|
||||||
return `<div class="item-expiry-badge ${badgeClass}">${formatDate(expiryDate)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display search results
|
|
||||||
function displayResults(results) {
|
|
||||||
resultsGrid.innerHTML = '';
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
resultsGrid.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
|
|
||||||
resultsInfo.textContent = 'No results';
|
|
||||||
} else {
|
|
||||||
resultsGrid.className = 'item-component-grid';
|
|
||||||
results.forEach(item => {
|
|
||||||
const comp = createItemComponent({
|
|
||||||
imgSrc: item.img,
|
|
||||||
imgAlt: item.name,
|
|
||||||
text1: item.name,
|
|
||||||
text2: `${item.location} — ${item.quantity} ${item.unit}`,
|
|
||||||
text3: `Expires: ${formatDate(item.expiryDate)}`,
|
|
||||||
editable: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add expiry badge
|
|
||||||
const badge = document.createElement('div');
|
|
||||||
badge.innerHTML = getExpiryBadgeHtml(item.expiryDate);
|
|
||||||
comp.appendChild(badge.firstChild);
|
|
||||||
|
|
||||||
resultsGrid.appendChild(comp);
|
|
||||||
});
|
|
||||||
resultsInfo.textContent = `Found ${results.length} item${results.length !== 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform search
|
|
||||||
function performSearch() {
|
|
||||||
const searchName = searchNameInput.value;
|
|
||||||
const location = locationSelect.value;
|
|
||||||
const minQty = parseInt(minQuantityInput.value) || 0;
|
|
||||||
const maxQty = parseInt(maxQuantityInput.value) || Infinity;
|
|
||||||
const minExpiry = minExpiryDateInput.value;
|
|
||||||
const maxExpiry = maxExpiryDateInput.value;
|
|
||||||
|
|
||||||
// Validate date range
|
|
||||||
if (minExpiry && maxExpiry && minExpiry > maxExpiry) {
|
|
||||||
alert('Start date cannot be after end date');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = searchInventory(searchName, location, minQty, maxQty, minExpiry, maxExpiry);
|
|
||||||
displayResults(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
function resetForm() {
|
|
||||||
searchForm.reset();
|
|
||||||
minQuantityInput.value = '0';
|
|
||||||
maxQuantityInput.value = '999';
|
|
||||||
locationSelect.value = 'All';
|
|
||||||
minExpiryDateInput.value = '';
|
|
||||||
maxExpiryDateInput.value = '';
|
|
||||||
resultsGrid.innerHTML = '';
|
|
||||||
resultsInfo.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
searchBtn.addEventListener('click', performSearch);
|
|
||||||
resetBtn.addEventListener('click', resetForm);
|
|
||||||
|
|
||||||
// Allow Enter key to search
|
|
||||||
searchNameInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') performSearch();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
populateLocations();
|
|
||||||
displayResults(searchInventory());
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
118
search.js
118
search.js
@@ -1,118 +0,0 @@
|
|||||||
// search.js - Search module with inventory data and filtering logic
|
|
||||||
|
|
||||||
// Helper function to create a date string
|
|
||||||
function getDate(daysFromNow) {
|
|
||||||
const date = new Date();
|
|
||||||
date.setDate(date.getDate() + daysFromNow);
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const inventoryData = [
|
|
||||||
// Pantry items
|
|
||||||
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
|
|
||||||
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },
|
|
||||||
{ id: 3, name: 'Cereal', location: 'Pantry', quantity: 2, unit: 'boxes', expiryDate: getDate(60), img: 'https://picsum.photos/seed/cereal/200/200' },
|
|
||||||
{ id: 4, name: 'Flour', location: 'Pantry', quantity: 1, unit: 'bag', expiryDate: getDate(150), img: 'https://picsum.photos/seed/flour/200/200' },
|
|
||||||
{ id: 5, name: 'Sugar', location: 'Pantry', quantity: 2, unit: 'bags', expiryDate: getDate(200), img: 'https://picsum.photos/seed/sugar/200/200' },
|
|
||||||
{ id: 6, name: 'Salt', location: 'Pantry', quantity: 1, unit: 'box', expiryDate: getDate(365), img: 'https://picsum.photos/seed/salt/200/200' },
|
|
||||||
{ id: 7, name: 'Olive Oil', location: 'Pantry', quantity: 2, unit: 'bottles', expiryDate: getDate(90), img: 'https://picsum.photos/seed/oil/200/200' },
|
|
||||||
{ id: 8, name: 'Canned Beans', location: 'Pantry', quantity: 12, unit: 'cans', expiryDate: getDate(-10), img: 'https://picsum.photos/seed/beans/200/200' },
|
|
||||||
|
|
||||||
// Fridge items
|
|
||||||
{ id: 9, name: 'Milk', location: 'Fridge', quantity: 1, unit: 'carton', expiryDate: getDate(5), img: 'https://picsum.photos/seed/milk/200/200' },
|
|
||||||
{ id: 10, name: 'Cheese', location: 'Fridge', quantity: 2, unit: 'blocks', expiryDate: getDate(30), img: 'https://picsum.photos/seed/cheese/200/200' },
|
|
||||||
{ id: 11, name: 'Greek Yogurt', location: 'Fridge', quantity: 3, unit: 'containers', expiryDate: getDate(7), img: 'https://picsum.photos/seed/yogurt/200/200' },
|
|
||||||
{ id: 12, name: 'Eggs', location: 'Fridge', quantity: 24, unit: 'eggs', expiryDate: getDate(21), img: 'https://picsum.photos/seed/eggs/200/200' },
|
|
||||||
{ id: 13, name: 'Butter', location: 'Fridge', quantity: 1, unit: 'pack', expiryDate: getDate(45), img: 'https://picsum.photos/seed/butter/200/200' },
|
|
||||||
{ id: 14, name: 'Chicken Salad', location: 'Fridge', quantity: 2, unit: 'containers', expiryDate: getDate(-2), img: 'https://picsum.photos/seed/salad/200/200' },
|
|
||||||
|
|
||||||
// Freezer items
|
|
||||||
{ id: 15, name: 'Ice Cream', location: 'Freezer', quantity: 1, unit: 'tub', expiryDate: getDate(90), img: 'https://picsum.photos/seed/icecream/200/200' },
|
|
||||||
{ id: 16, name: 'Frozen Vegetables', location: 'Freezer', quantity: 5, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/veggies/200/200' },
|
|
||||||
{ id: 17, name: 'Chicken Breast', location: 'Freezer', quantity: 4, unit: 'packages', expiryDate: getDate(120), img: 'https://picsum.photos/seed/chicken/200/200' },
|
|
||||||
{ id: 18, name: 'Ground Beef', location: 'Freezer', quantity: 3, unit: 'packages', expiryDate: getDate(150), img: 'https://picsum.photos/seed/beef/200/200' },
|
|
||||||
{ id: 19, name: 'Pizza', location: 'Freezer', quantity: 2, unit: 'boxes', expiryDate: getDate(200), img: 'https://picsum.photos/seed/pizza/200/200' },
|
|
||||||
{ id: 20, name: 'Ice', location: 'Freezer', quantity: 1, unit: 'bag', expiryDate: getDate(365), img: 'https://picsum.photos/seed/ice/200/200' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a date string (YYYY-MM-DD) to readable format (Mon DD, YYYY)
|
|
||||||
* @param {string} dateStr - ISO date string
|
|
||||||
* @returns {string} Formatted date
|
|
||||||
*/
|
|
||||||
export function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '';
|
|
||||||
const date = new Date(dateStr + 'T00:00:00');
|
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get expiry status of an item
|
|
||||||
* @param {string} expiryDate - ISO date string
|
|
||||||
* @returns {Object} Status object with text and color
|
|
||||||
*/
|
|
||||||
export function getExpiryStatus(expiryDate) {
|
|
||||||
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' };
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const expiry = new Date(expiryDate + 'T00:00:00');
|
|
||||||
const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysUntilExpiry < 0) {
|
|
||||||
return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` };
|
|
||||||
} else if (daysUntilExpiry === 0) {
|
|
||||||
return { status: 'Today', color: '#ff9800', days: 0, text: 'Expires today' };
|
|
||||||
} else if (daysUntilExpiry <= 7) {
|
|
||||||
return { status: 'Soon', color: '#ff9800', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` };
|
|
||||||
} else {
|
|
||||||
return { status: 'Fresh', color: '#4CAF50', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search and filter inventory items
|
|
||||||
* @param {string} searchName - Search term for item name (case insensitive)
|
|
||||||
* @param {string} selectedLocation - Filter by location ('All', 'Pantry', 'Fridge', 'Freezer')
|
|
||||||
* @param {number} minQuantity - Minimum quantity filter
|
|
||||||
* @param {number} maxQuantity - Maximum quantity filter
|
|
||||||
* @param {string} minExpiryDate - Minimum expiry date (YYYY-MM-DD format)
|
|
||||||
* @param {string} maxExpiryDate - Maximum expiry date (YYYY-MM-DD format)
|
|
||||||
* @returns {Array} Filtered inventory items
|
|
||||||
*/
|
|
||||||
export function searchInventory(searchName = '', selectedLocation = 'All', minQuantity = 0, maxQuantity = Infinity, minExpiryDate = '', maxExpiryDate = '') {
|
|
||||||
return inventoryData.filter(item => {
|
|
||||||
// Filter by name
|
|
||||||
const nameMatch = item.name.toLowerCase().includes(searchName.toLowerCase());
|
|
||||||
|
|
||||||
// Filter by location
|
|
||||||
const locationMatch = selectedLocation === 'All' || item.location === selectedLocation;
|
|
||||||
|
|
||||||
// Filter by quantity range
|
|
||||||
const quantityMatch = item.quantity >= minQuantity && item.quantity <= maxQuantity;
|
|
||||||
|
|
||||||
// Filter by expiry date range
|
|
||||||
let expiryMatch = true;
|
|
||||||
if (minExpiryDate || maxExpiryDate) {
|
|
||||||
const itemExpiry = item.expiryDate;
|
|
||||||
if (minExpiryDate && itemExpiry < minExpiryDate) {
|
|
||||||
expiryMatch = false;
|
|
||||||
}
|
|
||||||
if (maxExpiryDate && itemExpiry > maxExpiryDate) {
|
|
||||||
expiryMatch = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nameMatch && locationMatch && quantityMatch && expiryMatch;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique locations
|
|
||||||
* @returns {Array} List of unique locations
|
|
||||||
*/
|
|
||||||
export function getLocations() {
|
|
||||||
const locations = [...new Set(inventoryData.map(item => item.location))];
|
|
||||||
return ['All', ...locations.sort()];
|
|
||||||
}
|
|
||||||
364
src/App.css
Normal file
364
src/App.css
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-bg: #f4f7fb;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-muted: #f8fafc;
|
||||||
|
--color-surface-subtle: #fbfcfe;
|
||||||
|
--color-text: #1e293b;
|
||||||
|
--color-text-strong: #0f172a;
|
||||||
|
--color-text-soft: #475569;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-border: #dde5ef;
|
||||||
|
--color-border-strong: #cbd5e1;
|
||||||
|
--color-border-muted: #dbe4f0;
|
||||||
|
--color-border-subtle: #d9e2ef;
|
||||||
|
--color-rule: #5b5b5b;
|
||||||
|
--color-focus: #2563eb;
|
||||||
|
--color-focus-ring: rgba(37, 99, 235, 0.12);
|
||||||
|
--color-focus-ring-strong: rgba(37, 99, 235, 0.14);
|
||||||
|
--color-chart-track: #f2f2f2;
|
||||||
|
--color-video-bg: #000000;
|
||||||
|
--color-input-bg: #ffffff;
|
||||||
|
--color-input-focus-bg: #fffef0;
|
||||||
|
--shadow-panel: 0 16px 32px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow-soft: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
--nav-bg: #333333;
|
||||||
|
--nav-bg-hover: #111111;
|
||||||
|
--nav-text: #ffffff;
|
||||||
|
--nav-button-border: rgba(255, 255, 255, 0.35);
|
||||||
|
--nav-button-hover: rgba(255, 255, 255, 0.14);
|
||||||
|
--nav-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
--button-primary-bg: #4caf50;
|
||||||
|
--button-primary-hover: #45a049;
|
||||||
|
--button-primary-text: #ffffff;
|
||||||
|
--button-primary-ring: rgba(76, 175, 80, 0.3);
|
||||||
|
--button-secondary-bg: #dddddd;
|
||||||
|
--button-secondary-hover: #bbbbbb;
|
||||||
|
--button-secondary-text: #333333;
|
||||||
|
--button-danger-bg: #f44336;
|
||||||
|
--button-danger-hover: #da190b;
|
||||||
|
--button-danger-text: #ffffff;
|
||||||
|
--button-accent-bg: #2196f3;
|
||||||
|
--button-accent-hover: #0b7dda;
|
||||||
|
--button-accent-text: #ffffff;
|
||||||
|
--status-error-bg: #fff1f2;
|
||||||
|
--status-error-border: #fecdd3;
|
||||||
|
--status-error-text: #be123c;
|
||||||
|
--status-success-bg: #ecfdf5;
|
||||||
|
--status-success-border: #bbf7d0;
|
||||||
|
--status-success-text: #15803d;
|
||||||
|
--status-success-bg-strong: #c8e6c9;
|
||||||
|
--status-info-bg: #eff6ff;
|
||||||
|
--status-info-border: #bfdbfe;
|
||||||
|
--status-info-text: #1d4ed8;
|
||||||
|
--status-warning-bg: #fff3e0;
|
||||||
|
--status-warning-border: #ffe0b2;
|
||||||
|
--status-warning-text: #e65100;
|
||||||
|
--status-warning-text-strong: #d84315;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-bg: #0f172a;
|
||||||
|
--color-surface: #172033;
|
||||||
|
--color-surface-muted: #1e293b;
|
||||||
|
--color-surface-subtle: #22304a;
|
||||||
|
--color-text: #e2e8f0;
|
||||||
|
--color-text-strong: #f8fafc;
|
||||||
|
--color-text-soft: #cbd5e1;
|
||||||
|
--color-text-muted: #94a3b8;
|
||||||
|
--color-border: #334155;
|
||||||
|
--color-border-strong: #475569;
|
||||||
|
--color-border-muted: #42526b;
|
||||||
|
--color-border-subtle: #3f4f68;
|
||||||
|
--color-rule: #475569;
|
||||||
|
--color-focus: #60a5fa;
|
||||||
|
--color-focus-ring: rgba(96, 165, 250, 0.22);
|
||||||
|
--color-focus-ring-strong: rgba(96, 165, 250, 0.28);
|
||||||
|
--color-chart-track: #334155;
|
||||||
|
--color-video-bg: #020617;
|
||||||
|
--color-input-bg: #0b1220;
|
||||||
|
--color-input-focus-bg: #111c31;
|
||||||
|
--shadow-panel: 0 18px 36px rgba(2, 6, 23, 0.45);
|
||||||
|
--shadow-soft: 0 10px 24px rgba(2, 6, 23, 0.28);
|
||||||
|
--nav-bg: #020617;
|
||||||
|
--nav-bg-hover: #1e293b;
|
||||||
|
--nav-text: #e2e8f0;
|
||||||
|
--nav-button-border: rgba(148, 163, 184, 0.35);
|
||||||
|
--nav-button-hover: rgba(148, 163, 184, 0.16);
|
||||||
|
--nav-divider: rgba(148, 163, 184, 0.2);
|
||||||
|
--button-primary-bg: #22c55e;
|
||||||
|
--button-primary-hover: #16a34a;
|
||||||
|
--button-primary-text: #f8fafc;
|
||||||
|
--button-primary-ring: rgba(34, 197, 94, 0.28);
|
||||||
|
--button-secondary-bg: #334155;
|
||||||
|
--button-secondary-hover: #475569;
|
||||||
|
--button-secondary-text: #e2e8f0;
|
||||||
|
--button-danger-bg: #ef4444;
|
||||||
|
--button-danger-hover: #dc2626;
|
||||||
|
--button-danger-text: #f8fafc;
|
||||||
|
--button-accent-bg: #3b82f6;
|
||||||
|
--button-accent-hover: #2563eb;
|
||||||
|
--button-accent-text: #eff6ff;
|
||||||
|
--status-error-bg: #3b0d19;
|
||||||
|
--status-error-border: #7f1d1d;
|
||||||
|
--status-error-text: #fecdd3;
|
||||||
|
--status-success-bg: #052e1a;
|
||||||
|
--status-success-border: #166534;
|
||||||
|
--status-success-text: #bbf7d0;
|
||||||
|
--status-success-bg-strong: #14532d;
|
||||||
|
--status-info-bg: #0c2447;
|
||||||
|
--status-info-border: #1d4ed8;
|
||||||
|
--status-info-text: #bfdbfe;
|
||||||
|
--status-warning-bg: #422006;
|
||||||
|
--status-warning-border: #c2410c;
|
||||||
|
--status-warning-text: #fed7aa;
|
||||||
|
--status-warning-text-strong: #fdba74;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamically Changing the content padding depending on the width of the screen */
|
||||||
|
@media screen and (min-width: 1921px) and (max-width: 2560px) {
|
||||||
|
body {
|
||||||
|
padding-left: 20%;
|
||||||
|
padding-right: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1920px) {
|
||||||
|
body {
|
||||||
|
padding-left: 10%;
|
||||||
|
padding-right: 10%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
hr,
|
||||||
|
.btn,
|
||||||
|
.panel,
|
||||||
|
.auth-required,
|
||||||
|
.empty-state,
|
||||||
|
.field-group input,
|
||||||
|
.field-group select,
|
||||||
|
.field-group textarea {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-content {
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: xx-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: x-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-rule);
|
||||||
|
margin: 20px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--button-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--button-secondary-bg);
|
||||||
|
color: var(--button-secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--button-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--button-danger-bg);
|
||||||
|
color: var(--button-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: var(--button-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner--error {
|
||||||
|
background: var(--status-error-bg);
|
||||||
|
border-color: var(--status-error-border);
|
||||||
|
color: var(--status-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner--success {
|
||||||
|
background: var(--status-success-bg);
|
||||||
|
border-color: var(--status-success-border);
|
||||||
|
color: var(--status-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner--info {
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
border-color: var(--status-info-border);
|
||||||
|
color: var(--status-info-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-required,
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px dashed var(--color-border-strong);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input,
|
||||||
|
.field-group select,
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input::placeholder,
|
||||||
|
.field-group select::placeholder,
|
||||||
|
.field-group textarea::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group select:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-text {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
body {
|
||||||
|
padding-left: 16px !important;
|
||||||
|
padding-right: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row,
|
||||||
|
.section-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/App.jsx
Normal file
84
src/App.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigate, Routes, Route } from 'react-router-dom'
|
||||||
|
import Navbar from './components/Navbar/Navbar.jsx'
|
||||||
|
import AdminPage from './pages/AdminPage/AdminPage.jsx'
|
||||||
|
import HomePage from './pages/HomePage/HomePage.jsx'
|
||||||
|
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
|
||||||
|
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx'
|
||||||
|
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx'
|
||||||
|
import SearchPage from './pages/SearchPage/SearchPage.jsx'
|
||||||
|
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx'
|
||||||
|
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
|
||||||
|
import UsersPage from './pages/UsersPage/UsersPage.jsx'
|
||||||
|
import { useAuth } from './context/AuthContext.jsx'
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'pantry-theme'
|
||||||
|
|
||||||
|
function resolveInitialTheme() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||||
|
return storedTheme
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures and fall back to system preference.
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialTheme = resolveInitialTheme()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.dataset.theme = initialTheme
|
||||||
|
document.documentElement.style.colorScheme = initialTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated, isSiteAdmin } = useAuth()
|
||||||
|
const [theme, setTheme] = useState(initialTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
document.documentElement.style.colorScheme = theme
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures and keep the in-memory preference.
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar theme={theme} onToggleTheme={toggleTheme} />
|
||||||
|
<div id="page-content">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/admin" element={isSiteAdmin ? <AdminPage /> : <Navigate to="/" replace />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/inventory" element={<InventoryPage />} />
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
<Route path="/shopping-lists" element={<ShoppingListsPage />} />
|
||||||
|
<Route path="/meal-planners" element={<MealPlannersPage />} />
|
||||||
|
<Route path="/barcode" element={<BarcodePage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
</Routes>
|
||||||
|
) : (
|
||||||
|
<HomePage />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
487
src/api/client.js
Normal file
487
src/api/client.js
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
const SESSION_STORAGE_KEY = 'pantry-management-session'
|
||||||
|
const SESSION_CHANGE_EVENT = 'pantry-management-session-change'
|
||||||
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '')
|
||||||
|
|
||||||
|
let refreshPromise = null
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status = 0, data = null) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path) {
|
||||||
|
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredSession(rawValue) {
|
||||||
|
if (!rawValue) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchSessionChange(session) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
|
||||||
|
detail: session,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredSession() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
|
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(session) {
|
||||||
|
if (typeof window === 'undefined') return session
|
||||||
|
|
||||||
|
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||||
|
dispatchSessionChange(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||||
|
dispatchSessionChange(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToSessionChanges(listener) {
|
||||||
|
if (typeof window === 'undefined') return () => {}
|
||||||
|
|
||||||
|
function handleCustomEvent(event) {
|
||||||
|
listener(event.detail ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStorageEvent(event) {
|
||||||
|
if (event.key !== SESSION_STORAGE_KEY) return
|
||||||
|
listener(parseStoredSession(event.newValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
||||||
|
window.addEventListener('storage', handleStorageEvent)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
||||||
|
window.removeEventListener('storage', handleStorageEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readResponse(response) {
|
||||||
|
const text = await response.text()
|
||||||
|
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
|
||||||
|
if (contentType.includes('json')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(data, fallbackMessage) {
|
||||||
|
if (!data) return fallbackMessage
|
||||||
|
|
||||||
|
if (typeof data === 'string' && data.trim()) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.message === 'string' && data.message.trim()) {
|
||||||
|
return data.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.Message === 'string' && data.Message.trim()) {
|
||||||
|
return data.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.error === 'string' && data.error.trim()) {
|
||||||
|
return data.error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.errors && typeof data.errors === 'object') {
|
||||||
|
const messages = Object.values(data.errors)
|
||||||
|
.flatMap(value => Array.isArray(value) ? value : [value])
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (messages.length > 0) {
|
||||||
|
return messages.join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.title === 'string' && data.title.trim()) {
|
||||||
|
return data.title
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh(session) {
|
||||||
|
if (!session?.refreshToken) {
|
||||||
|
clearSession()
|
||||||
|
throw new ApiError('Your session expired. Sign in again.', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(buildUrl('/api/auth/refresh-token'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiError(
|
||||||
|
'Unable to refresh your session. Make sure the API is running and reachable.',
|
||||||
|
0,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readResponse(response)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
clearSession()
|
||||||
|
throw new ApiError(
|
||||||
|
extractErrorMessage(data, 'Your session expired. Sign in again.'),
|
||||||
|
response.status,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveSession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
user: data.user ?? session.user ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh(getStoredSession())
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(path, options = {}) {
|
||||||
|
const {
|
||||||
|
method = 'GET',
|
||||||
|
body,
|
||||||
|
headers = {},
|
||||||
|
skipAuth = false,
|
||||||
|
retryOnAuthFailure = true,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const session = getStoredSession()
|
||||||
|
const requestHeaders = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipAuth && session?.accessToken) {
|
||||||
|
requestHeaders.Authorization = `Bearer ${session.accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let response
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(buildUrl(path), {
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw new ApiError(
|
||||||
|
'Unable to reach the API. Make sure the backend is running and the certificate is trusted.',
|
||||||
|
0,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readResponse(response)
|
||||||
|
|
||||||
|
if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) {
|
||||||
|
await refreshSession()
|
||||||
|
return requestJson(path, { ...options, retryOnAuthFailure: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
|
||||||
|
response.status,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
async register(payload) {
|
||||||
|
return requestJson('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(payload) {
|
||||||
|
const data = await requestJson('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
saveSession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
user: data.user ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await requestJson('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileApi = {
|
||||||
|
getProfile() {
|
||||||
|
return requestJson('/api/profile')
|
||||||
|
},
|
||||||
|
|
||||||
|
getProtectedData() {
|
||||||
|
return requestJson('/api/profile/data')
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile(payload) {
|
||||||
|
return requestJson('/api/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsApi = {
|
||||||
|
getLocations() {
|
||||||
|
return requestJson('/api/locations')
|
||||||
|
},
|
||||||
|
|
||||||
|
getLocationHistory(id) {
|
||||||
|
return requestJson(`/api/locations/${id}/history`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createLocation(payload) {
|
||||||
|
return requestJson('/api/locations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLocation(id, payload) {
|
||||||
|
return requestJson(`/api/locations/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLocation(id) {
|
||||||
|
return requestJson(`/api/locations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inventoryApi = {
|
||||||
|
getInventoryItems() {
|
||||||
|
return requestJson('/api/inventoryitems')
|
||||||
|
},
|
||||||
|
|
||||||
|
getInventoryItem(id) {
|
||||||
|
return requestJson(`/api/inventoryitems/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createInventoryItem(payload) {
|
||||||
|
return requestJson('/api/inventoryitems', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateInventoryItem(id, payload) {
|
||||||
|
return requestJson(`/api/inventoryitems/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteInventoryItem(id) {
|
||||||
|
return requestJson(`/api/inventoryitems/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchApi = {
|
||||||
|
searchLocations(query) {
|
||||||
|
return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
searchItems(query) {
|
||||||
|
return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const householdsApi = {
|
||||||
|
getHouseholds() {
|
||||||
|
return requestJson('/api/households')
|
||||||
|
},
|
||||||
|
|
||||||
|
getHouseholdHistory(id) {
|
||||||
|
return requestJson(`/api/households/${id}/history`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createHousehold(payload) {
|
||||||
|
return requestJson('/api/households', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHousehold(id, payload) {
|
||||||
|
return requestJson(`/api/households/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
inviteHouseholdMember(id, payload) {
|
||||||
|
return requestJson(`/api/households/${id}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveHousehold(id) {
|
||||||
|
return requestJson(`/api/households/${id}/leave`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
getUsers() {
|
||||||
|
return requestJson('/api/users')
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser(id) {
|
||||||
|
return requestJson(`/api/users/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser(id, payload) {
|
||||||
|
return requestJson(`/api/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shoppingListsApi = {
|
||||||
|
getShoppingLists() {
|
||||||
|
return requestJson('/api/shoppinglists')
|
||||||
|
},
|
||||||
|
|
||||||
|
getShoppingList(id) {
|
||||||
|
return requestJson(`/api/shoppinglists/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createShoppingList(payload) {
|
||||||
|
return requestJson('/api/shoppinglists', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateShoppingList(id, payload) {
|
||||||
|
return requestJson(`/api/shoppinglists/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteShoppingList(id) {
|
||||||
|
return requestJson(`/api/shoppinglists/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mealPlannersApi = {
|
||||||
|
getMealPlanners() {
|
||||||
|
return requestJson('/api/mealplanners')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMealPlanner(id) {
|
||||||
|
return requestJson(`/api/mealplanners/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createMealPlanner(payload) {
|
||||||
|
return requestJson('/api/mealplanners', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMealPlanner(id, payload) {
|
||||||
|
return requestJson(`/api/mealplanners/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMealPlanner(id) {
|
||||||
|
return requestJson(`/api/mealplanners/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
85
src/components/ItemCard/ItemCard.css
Normal file
85
src/components/ItemCard/ItemCard.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
.item-component-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__img {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__img img {
|
||||||
|
display: block;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__line {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__line--title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__line--subtitle {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__line--desc {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px;
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-component__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.item-component-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.item-component-grid {
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/components/ItemCard/ItemCard.jsx
Normal file
29
src/components/ItemCard/ItemCard.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import './ItemCard.css'
|
||||||
|
|
||||||
|
function ItemCard({ imgSrc = '', imgAlt = '', text1 = '', text2 = '', text3 = '', editable = false, imgWidth = 80, imgHeight = 80, children }) {
|
||||||
|
return (
|
||||||
|
<div className="item-component">
|
||||||
|
<div className="item-component__img">
|
||||||
|
{imgSrc && <img src={imgSrc} alt={imgAlt} width={imgWidth} height={imgHeight} />}
|
||||||
|
</div>
|
||||||
|
<div className="item-component__content">
|
||||||
|
{editable ? (
|
||||||
|
<>
|
||||||
|
<textarea className="item-component__textarea item-component__line--title" defaultValue={text1} />
|
||||||
|
<textarea className="item-component__textarea item-component__line--subtitle" defaultValue={text2} />
|
||||||
|
<textarea className="item-component__textarea item-component__line--desc" defaultValue={text3} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="item-component__line item-component__line--title">{text1}</div>
|
||||||
|
<div className="item-component__line item-component__line--subtitle">{text2}</div>
|
||||||
|
<div className="item-component__line item-component__line--desc">{text3}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ItemCard
|
||||||
211
src/components/Navbar/Navbar.css
Normal file
211
src/components/Navbar/Navbar.css
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
.navbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--nav-bg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a {
|
||||||
|
display: block;
|
||||||
|
color: var(--nav-text);
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a:hover {
|
||||||
|
background-color: var(--nav-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a.active {
|
||||||
|
background-color: var(--nav-bg-hover);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-status {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-status__text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
border: 1px solid var(--nav-button-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background-color: var(--nav-button-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-theme-toggle__icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.72;
|
||||||
|
transition: background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-theme-toggle__icon.is-active {
|
||||||
|
background-color: var(--nav-button-hover);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-theme-toggle__icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.nav-status {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 0;
|
||||||
|
border-top: 1px solid var(--nav-divider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.nav-menu-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__label {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__icon span {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__icon.is-open span:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__icon.is-open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-toggle__icon.is-open span:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list--open {
|
||||||
|
max-height: 640px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li + li {
|
||||||
|
border-top: 1px solid var(--nav-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a.active {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-status {
|
||||||
|
padding: 16px 18px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid var(--nav-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-status__text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/components/Navbar/Navbar.jsx
Normal file
129
src/components/Navbar/Navbar.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import './Navbar.css'
|
||||||
|
|
||||||
|
const MOBILE_NAV_BREAKPOINT = 760
|
||||||
|
|
||||||
|
function formatUserDisplayName(user) {
|
||||||
|
const fullName = [user?.firstName, user?.lastName]
|
||||||
|
.filter(part => typeof part === 'string' && part.trim())
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return fullName || user?.email || 'Signed in'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2.5V5M12 19V21.5M4.93 4.93L6.7 6.7M17.3 17.3l1.77 1.77M2.5 12H5M19 12h2.5M4.93 19.07L6.7 17.3M17.3 6.7l1.77-1.77" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 9 9 0 1 0 20 14.5Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Navbar({ theme, onToggleTheme }) {
|
||||||
|
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
const nextThemeLabel = theme === 'dark' ? 'Switch to light' : 'Switch to dark'
|
||||||
|
const userDisplayName = formatUserDisplayName(user)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}, [location.pathname, isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
if (window.innerWidth > MOBILE_NAV_BREAKPOINT) {
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="navbar" aria-label="Primary navigation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-menu-toggle"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-controls="primary-navigation"
|
||||||
|
onClick={() => setIsMenuOpen(currentValue => !currentValue)}
|
||||||
|
>
|
||||||
|
<span className="nav-menu-toggle__label">Menu</span>
|
||||||
|
<span className={`nav-menu-toggle__icon ${isMenuOpen ? 'is-open' : ''}`} aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ul id="primary-navigation" className={`nav-list ${isMenuOpen ? 'nav-list--open' : ''}`}>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<li><NavLink to="/" onClick={closeMenu}>Homepage</NavLink></li>
|
||||||
|
<li><NavLink to="/profile" onClick={closeMenu}>Profile</NavLink></li>
|
||||||
|
{isSiteAdmin && <li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li>}
|
||||||
|
<li><NavLink to="/inventory" onClick={closeMenu}>Inventory</NavLink></li>
|
||||||
|
<li><NavLink to="/search" onClick={closeMenu}>Search</NavLink></li>
|
||||||
|
<li><NavLink to="/shopping-lists" onClick={closeMenu}>Shopping Lists</NavLink></li>
|
||||||
|
<li><NavLink to="/meal-planners" onClick={closeMenu}>Meal Planners</NavLink></li>
|
||||||
|
<li><NavLink to="/barcode" onClick={closeMenu}>Barcode Scanner</NavLink></li>
|
||||||
|
{isSiteAdmin && <li><NavLink to="/users" onClick={closeMenu}>Users</NavLink></li>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<li><NavLink to="/" onClick={closeMenu}>Login</NavLink></li>
|
||||||
|
)}
|
||||||
|
<li className="nav-status">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-button nav-theme-toggle"
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
aria-pressed={theme === 'dark'}
|
||||||
|
aria-label={`${nextThemeLabel} mode`}
|
||||||
|
title={`${nextThemeLabel} mode`}
|
||||||
|
>
|
||||||
|
<span className={`nav-theme-toggle__icon ${theme === 'light' ? 'is-active' : ''}`}>
|
||||||
|
<SunIcon />
|
||||||
|
</span>
|
||||||
|
<span className={`nav-theme-toggle__icon ${theme === 'dark' ? 'is-active' : ''}`}>
|
||||||
|
<MoonIcon />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span className="nav-status__text">{isAuthenticated ? userDisplayName : 'Signed out'}</span>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<button type="button" className="nav-button" onClick={() => {
|
||||||
|
closeMenu()
|
||||||
|
logout()
|
||||||
|
}}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Navbar
|
||||||
34
src/components/PieChart/PieChart.css
Normal file
34
src/components/PieChart/PieChart.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.chart-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle {
|
||||||
|
transition: stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
font-family: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
50
src/components/PieChart/PieChart.jsx
Normal file
50
src/components/PieChart/PieChart.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import './PieChart.css'
|
||||||
|
|
||||||
|
const RADIUS = 10
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||||
|
|
||||||
|
function PieChart({ segments }) {
|
||||||
|
const computedSegments = useMemo(() => {
|
||||||
|
const total = segments.reduce((acc, seg) => acc + seg.value, 0)
|
||||||
|
let currentOffset = 0
|
||||||
|
return segments.map(seg => {
|
||||||
|
const fraction = seg.value / total
|
||||||
|
const segmentLength = fraction * CIRCUMFERENCE
|
||||||
|
const offset = currentOffset
|
||||||
|
currentOffset += segmentLength
|
||||||
|
return { ...seg, segmentLength, offset }
|
||||||
|
})
|
||||||
|
}, [segments])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-container">
|
||||||
|
<svg width="350" height="350" viewBox="0 0 42 42">
|
||||||
|
<circle cx="21" cy="21" r={RADIUS} fill="transparent" stroke="var(--color-chart-track)" strokeWidth="20" />
|
||||||
|
{computedSegments.map((seg, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx="21"
|
||||||
|
cy="21"
|
||||||
|
r={RADIUS}
|
||||||
|
fill="transparent"
|
||||||
|
stroke={seg.color}
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeDasharray={`${seg.segmentLength} ${CIRCUMFERENCE}`}
|
||||||
|
strokeDashoffset={-seg.offset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="legend">
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<div className="legend-item" key={i}>
|
||||||
|
<span className="dot" style={{ background: seg.color }} />
|
||||||
|
<span>{seg.label}: <strong>{seg.value}</strong></span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PieChart
|
||||||
106
src/context/AuthContext.jsx
Normal file
106
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
authApi,
|
||||||
|
getStoredSession,
|
||||||
|
profileApi,
|
||||||
|
saveSession,
|
||||||
|
subscribeToSessionChanges,
|
||||||
|
} from '../api/client.js'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin'])
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [session, setSession] = useState(() => getStoredSession())
|
||||||
|
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
|
||||||
|
|
||||||
|
function setCurrentUserProfile(profile) {
|
||||||
|
const currentSession = getStoredSession()
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveSession({
|
||||||
|
...currentSession,
|
||||||
|
user: profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshProfile() {
|
||||||
|
const currentSession = getStoredSession()
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await profileApi.getProfile()
|
||||||
|
setCurrentUserProfile(profile)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => subscribeToSessionChanges(setSession), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function syncStoredSession() {
|
||||||
|
const existingSession = getStoredSession()
|
||||||
|
|
||||||
|
if (!existingSession) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitializing(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await profileApi.getProfile()
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setCurrentUserProfile(profile)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The API client already clears invalid sessions after a failed refresh.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStoredSession()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const user = session?.user ?? null
|
||||||
|
const userRoles = Array.isArray(user?.roles) ? user.roles : []
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
isAuthenticated: Boolean(session?.accessToken),
|
||||||
|
isSiteAdmin: userRoles.some(role => SITE_ADMIN_ROLES.has(role)),
|
||||||
|
initializing,
|
||||||
|
login: authApi.login,
|
||||||
|
register: authApi.register,
|
||||||
|
logout: authApi.logout,
|
||||||
|
refreshProfile,
|
||||||
|
setCurrentUserProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const value = useContext(AuthContext)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useAuth must be used inside an AuthProvider.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
33
src/data/inventory.js
Normal file
33
src/data/inventory.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
function getDate(daysFromNow) {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() + daysFromNow)
|
||||||
|
return date.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inventoryData = [
|
||||||
|
// Pantry items
|
||||||
|
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
|
||||||
|
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },
|
||||||
|
{ id: 3, name: 'Cereal', location: 'Pantry', quantity: 2, unit: 'boxes', expiryDate: getDate(60), img: 'https://picsum.photos/seed/cereal/200/200' },
|
||||||
|
{ id: 4, name: 'Flour', location: 'Pantry', quantity: 1, unit: 'bag', expiryDate: getDate(150), img: 'https://picsum.photos/seed/flour/200/200' },
|
||||||
|
{ id: 5, name: 'Sugar', location: 'Pantry', quantity: 2, unit: 'bags', expiryDate: getDate(200), img: 'https://picsum.photos/seed/sugar/200/200' },
|
||||||
|
{ id: 6, name: 'Salt', location: 'Pantry', quantity: 1, unit: 'box', expiryDate: getDate(365), img: 'https://picsum.photos/seed/salt/200/200' },
|
||||||
|
{ id: 7, name: 'Olive Oil', location: 'Pantry', quantity: 2, unit: 'bottles', expiryDate: getDate(90), img: 'https://picsum.photos/seed/oil/200/200' },
|
||||||
|
{ id: 8, name: 'Canned Beans', location: 'Pantry', quantity: 12, unit: 'cans', expiryDate: getDate(-10), img: 'https://picsum.photos/seed/beans/200/200' },
|
||||||
|
|
||||||
|
// Fridge items
|
||||||
|
{ id: 9, name: 'Milk', location: 'Fridge', quantity: 1, unit: 'carton', expiryDate: getDate(5), img: 'https://picsum.photos/seed/milk/200/200' },
|
||||||
|
{ id: 10, name: 'Cheese', location: 'Fridge', quantity: 2, unit: 'blocks', expiryDate: getDate(30), img: 'https://picsum.photos/seed/cheese/200/200' },
|
||||||
|
{ id: 11, name: 'Greek Yogurt', location: 'Fridge', quantity: 3, unit: 'containers', expiryDate: getDate(7), img: 'https://picsum.photos/seed/yogurt/200/200' },
|
||||||
|
{ id: 12, name: 'Eggs', location: 'Fridge', quantity: 24, unit: 'eggs', expiryDate: getDate(21), img: 'https://picsum.photos/seed/eggs/200/200' },
|
||||||
|
{ id: 13, name: 'Butter', location: 'Fridge', quantity: 1, unit: 'pack', expiryDate: getDate(45), img: 'https://picsum.photos/seed/butter/200/200' },
|
||||||
|
{ id: 14, name: 'Chicken Salad', location: 'Fridge', quantity: 2, unit: 'containers', expiryDate: getDate(-2), img: 'https://picsum.photos/seed/salad/200/200' },
|
||||||
|
|
||||||
|
// Freezer items
|
||||||
|
{ id: 15, name: 'Ice Cream', location: 'Freezer', quantity: 1, unit: 'tub', expiryDate: getDate(90), img: 'https://picsum.photos/seed/icecream/200/200' },
|
||||||
|
{ id: 16, name: 'Frozen Vegetables', location: 'Freezer', quantity: 5, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/veggies/200/200' },
|
||||||
|
{ id: 17, name: 'Chicken Breast', location: 'Freezer', quantity: 4, unit: 'packages', expiryDate: getDate(120), img: 'https://picsum.photos/seed/chicken/200/200' },
|
||||||
|
{ id: 18, name: 'Ground Beef', location: 'Freezer', quantity: 3, unit: 'packages', expiryDate: getDate(150), img: 'https://picsum.photos/seed/beef/200/200' },
|
||||||
|
{ id: 19, name: 'Pizza', location: 'Freezer', quantity: 2, unit: 'boxes', expiryDate: getDate(200), img: 'https://picsum.photos/seed/pizza/200/200' },
|
||||||
|
{ id: 20, name: 'Ice', location: 'Freezer', quantity: 1, unit: 'bag', expiryDate: getDate(365), img: 'https://picsum.photos/seed/ice/200/200' },
|
||||||
|
]
|
||||||
16
src/main.jsx
Normal file
16
src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import { AuthProvider } from './context/AuthContext.jsx'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
101
src/pages/AdminPage/AdminPage.css
Normal file
101
src/pages/AdminPage/AdminPage.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
.admin-summary-panel {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-household-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-household-copy {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-household-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge,
|
||||||
|
.member-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
color: var(--status-info-text);
|
||||||
|
border: 1px solid var(--status-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-badge {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-created-user-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-created-user-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.member-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
706
src/pages/AdminPage/AdminPage.jsx
Normal file
706
src/pages/AdminPage/AdminPage.jsx
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { householdsApi, usersApi } from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import { formatDate } from '../../utils/searchUtils.js'
|
||||||
|
import './AdminPage.css'
|
||||||
|
|
||||||
|
const EMPTY_HOUSEHOLD_FORM = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_USER_FORM = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPersonName(person) {
|
||||||
|
const fullName = [person.firstName, person.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return fullName || person.email || 'Unnamed person'
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminPage() {
|
||||||
|
const { isAuthenticated, isSiteAdmin, register } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [households, setHouseholds] = useState([])
|
||||||
|
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
|
||||||
|
const [editingHouseholdId, setEditingHouseholdId] = useState('')
|
||||||
|
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
|
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
||||||
|
const [createdUser, setCreatedUser] = useState(null)
|
||||||
|
const [householdHistory, setHouseholdHistory] = useState([])
|
||||||
|
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
|
||||||
|
const [householdHistoryError, setHouseholdHistoryError] = useState('')
|
||||||
|
|
||||||
|
async function loadHouseholds(preferredSelectionId = '') {
|
||||||
|
const response = await householdsApi.getHouseholds()
|
||||||
|
const nextHouseholds = Array.isArray(response) ? response : []
|
||||||
|
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setSelectedHouseholdId(currentSelectionId => {
|
||||||
|
const targetSelectionId = preferredSelectionId || currentSelectionId
|
||||||
|
|
||||||
|
if (nextHouseholds.some(household => household.id === targetSelectionId)) {
|
||||||
|
return targetSelectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextHouseholds[0]?.id ?? ''
|
||||||
|
})
|
||||||
|
setEditingHouseholdId(currentEditingId => (
|
||||||
|
nextHouseholds.some(household => household.id === currentEditingId)
|
||||||
|
? currentEditingId
|
||||||
|
: ''
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialHouseholds() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setHouseholds([])
|
||||||
|
setSelectedHouseholdId('')
|
||||||
|
setEditingHouseholdId('')
|
||||||
|
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
|
||||||
|
setInviteEmail('')
|
||||||
|
setUserForm(EMPTY_USER_FORM)
|
||||||
|
setCreatedUser(null)
|
||||||
|
setHouseholdHistory([])
|
||||||
|
setHouseholdHistoryLoading(false)
|
||||||
|
setHouseholdHistoryError('')
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await householdsApi.getHouseholds()
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const nextHouseholds = Array.isArray(response) ? response : []
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setSelectedHouseholdId(nextHouseholds[0]?.id ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialHouseholds()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadSelectedHouseholdHistory() {
|
||||||
|
if (!isAuthenticated || !selectedHouseholdId) {
|
||||||
|
setHouseholdHistory([])
|
||||||
|
setHouseholdHistoryLoading(false)
|
||||||
|
setHouseholdHistoryError('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHouseholdHistoryLoading(true)
|
||||||
|
setHouseholdHistoryError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await householdsApi.getHouseholdHistory(selectedHouseholdId)
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setHouseholdHistory(Array.isArray(response) ? response : [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHouseholdHistory([])
|
||||||
|
setHouseholdHistoryError(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHouseholdHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSelectedHouseholdHistory()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, selectedHouseholdId])
|
||||||
|
|
||||||
|
const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null
|
||||||
|
const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null
|
||||||
|
const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
|
||||||
|
const canSubmitHouseholdForm = editingHouseholdId
|
||||||
|
? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin))
|
||||||
|
: isSiteAdmin
|
||||||
|
const createdUserRoles = Array.isArray(createdUser?.roles) ? createdUser.roles : []
|
||||||
|
|
||||||
|
function resetHouseholdEditor() {
|
||||||
|
setEditingHouseholdId('')
|
||||||
|
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUserForm() {
|
||||||
|
setUserForm(EMPTY_USER_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHouseholdSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = householdForm.name.trim()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setErrorMessage('Household name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSubmitHouseholdForm) {
|
||||||
|
setErrorMessage(editingHouseholdId
|
||||||
|
? 'You can only edit households you administer.'
|
||||||
|
: 'Only site admins can create households.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description: householdForm.description.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = editingHouseholdId
|
||||||
|
? await householdsApi.updateHousehold(editingHouseholdId, payload)
|
||||||
|
: await householdsApi.createHousehold(payload)
|
||||||
|
|
||||||
|
await loadHouseholds(result?.id ?? editingHouseholdId)
|
||||||
|
resetHouseholdEditor()
|
||||||
|
setStatusMessage(editingHouseholdId ? 'Household updated.' : 'Household created.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInviteSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const email = inviteEmail.trim()
|
||||||
|
|
||||||
|
if (!selectedHouseholdId) {
|
||||||
|
setErrorMessage('Select a household before sending an invite.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
setErrorMessage('Invite email is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageSelectedHousehold) {
|
||||||
|
setErrorMessage('You can only invite users to households you administer.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await householdsApi.inviteHouseholdMember(selectedHouseholdId, { email })
|
||||||
|
await loadHouseholds(selectedHouseholdId)
|
||||||
|
setInviteEmail('')
|
||||||
|
setStatusMessage('Invitation sent.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUserSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const email = userForm.email.trim()
|
||||||
|
|
||||||
|
if (!isSiteAdmin) {
|
||||||
|
setErrorMessage('Only site admins can create users from the admin page.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
setErrorMessage('User email is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userForm.password) {
|
||||||
|
setErrorMessage('A password is required to create a user.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userForm.password !== userForm.confirmPassword) {
|
||||||
|
setErrorMessage('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await register({
|
||||||
|
email,
|
||||||
|
password: userForm.password,
|
||||||
|
confirmPassword: userForm.confirmPassword,
|
||||||
|
firstName: userForm.firstName.trim() || null,
|
||||||
|
lastName: userForm.lastName.trim() || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await usersApi.getUsers()
|
||||||
|
const matchingUser = Array.isArray(users)
|
||||||
|
? users.find(existingUser => existingUser.email?.toLowerCase() === email.toLowerCase())
|
||||||
|
: null
|
||||||
|
|
||||||
|
setCreatedUser(matchingUser ?? {
|
||||||
|
email,
|
||||||
|
firstName: userForm.firstName.trim(),
|
||||||
|
lastName: userForm.lastName.trim(),
|
||||||
|
roles: [],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setCreatedUser({
|
||||||
|
email,
|
||||||
|
firstName: userForm.firstName.trim(),
|
||||||
|
lastName: userForm.lastName.trim(),
|
||||||
|
roles: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resetUserForm()
|
||||||
|
setStatusMessage(result?.message || 'User created.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLeaveHousehold(householdId) {
|
||||||
|
const confirmed = window.confirm('Leave this household?')
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await householdsApi.leaveHousehold(householdId)
|
||||||
|
await loadHouseholds(selectedHouseholdId === householdId ? '' : selectedHouseholdId)
|
||||||
|
|
||||||
|
if (selectedHouseholdId === householdId) {
|
||||||
|
setInviteEmail('')
|
||||||
|
resetHouseholdEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Household left.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before using the household administration endpoints.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Processing admin request...</div>}
|
||||||
|
|
||||||
|
<section className="panel admin-summary-panel">
|
||||||
|
<h3>Household Summary</h3>
|
||||||
|
<div className="stats-grid admin-stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Households</span>
|
||||||
|
<strong>{households.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Managed Households</span>
|
||||||
|
<strong>{households.filter(household => household.isCurrentUserHouseholdAdmin || isSiteAdmin).length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Members</span>
|
||||||
|
<strong>{households.reduce((count, household) => count + (household.members?.length ?? 0), 0)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="management-grid admin-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editingHouseholdId ? 'Edit Household' : 'Create Household'}</h3>
|
||||||
|
{editingHouseholdId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
|
||||||
|
New household
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSiteAdmin && !editingHouseholdId && (
|
||||||
|
<p className="form-note">
|
||||||
|
Only site admins can create households. Household admins can still edit an existing household from the list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleHouseholdSubmit}>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="household-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="household-name"
|
||||||
|
type="text"
|
||||||
|
value={householdForm.name}
|
||||||
|
onChange={event => setHouseholdForm(current => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="Main Household"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="household-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="household-description"
|
||||||
|
rows="3"
|
||||||
|
value={householdForm.description}
|
||||||
|
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
|
||||||
|
placeholder="Shared pantry and fridge access"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={!canSubmitHouseholdForm}>
|
||||||
|
{editingHouseholdId ? 'Update household' : 'Create household'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Invite Member</h3>
|
||||||
|
<span className="subtle-text">{selectedHousehold?.name || 'No household selected'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedHousehold ? (
|
||||||
|
<div className="empty-state compact-empty-state">Select a household to invite a member.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!canManageSelectedHousehold && (
|
||||||
|
<p className="form-note">
|
||||||
|
You can only invite users to households you administer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleInviteSubmit}>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="invite-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={event => setInviteEmail(event.target.value)}
|
||||||
|
placeholder="member@example.com"
|
||||||
|
disabled={!canManageSelectedHousehold}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={!canManageSelectedHousehold}>
|
||||||
|
Send invite
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setInviteEmail('')}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Create User</h3>
|
||||||
|
<span className="subtle-text">{createdUser?.email || 'Register endpoint'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="form-note">
|
||||||
|
{isSiteAdmin
|
||||||
|
? 'This creates a new account with the register endpoint. Use the users page afterwards if you need to assign site-admin roles.'
|
||||||
|
: 'Only site admins can create users from this page.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleUserSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="register-first-name">First Name</label>
|
||||||
|
<input
|
||||||
|
id="register-first-name"
|
||||||
|
type="text"
|
||||||
|
value={userForm.firstName}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, firstName: event.target.value }))}
|
||||||
|
placeholder="Jordan"
|
||||||
|
disabled={!isSiteAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="register-last-name">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="register-last-name"
|
||||||
|
type="text"
|
||||||
|
value={userForm.lastName}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, lastName: event.target.value }))}
|
||||||
|
placeholder="Lee"
|
||||||
|
disabled={!isSiteAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="register-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
value={userForm.email}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, email: event.target.value }))}
|
||||||
|
placeholder="new-user@example.com"
|
||||||
|
disabled={!isSiteAdmin}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="register-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="register-password"
|
||||||
|
type="password"
|
||||||
|
value={userForm.password}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, password: event.target.value }))}
|
||||||
|
placeholder="Create a password"
|
||||||
|
disabled={!isSiteAdmin}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="register-confirm-password">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
id="register-confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={userForm.confirmPassword}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, confirmPassword: event.target.value }))}
|
||||||
|
placeholder="Repeat the password"
|
||||||
|
disabled={!isSiteAdmin}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={!isSiteAdmin}>
|
||||||
|
Create user
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetUserForm} disabled={!isSiteAdmin}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{createdUser && (
|
||||||
|
<div className="admin-created-user-card">
|
||||||
|
<div className="admin-created-user-copy">
|
||||||
|
<strong>{formatPersonName(createdUser)}</strong>
|
||||||
|
<div className="entity-meta">{createdUser.email || 'No email provided.'}</div>
|
||||||
|
<div className="entity-meta">ID: {createdUser.id || 'Not available'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-badge-row">
|
||||||
|
{createdUserRoles.length === 0 ? (
|
||||||
|
<span className="member-badge">No roles assigned</span>
|
||||||
|
) : (
|
||||||
|
createdUserRoles.map(role => (
|
||||||
|
<span className="member-badge" key={role}>{role}</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Household History</h3>
|
||||||
|
<span className="subtle-text">{selectedHousehold?.name || 'No household selected'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{householdHistoryLoading && (
|
||||||
|
<div className="status-banner status-banner--info">Loading household history...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{householdHistoryError && (
|
||||||
|
<div className="status-banner status-banner--error">{householdHistoryError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedHousehold ? (
|
||||||
|
<div className="empty-state compact-empty-state">Select a household to review its audit history.</div>
|
||||||
|
) : householdHistory.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No household history was returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="entity-list">
|
||||||
|
{householdHistory.map(entry => (
|
||||||
|
<article className="entity-row" key={entry.id}>
|
||||||
|
<div className="entity-copy">
|
||||||
|
<strong>{entry.action}</strong>
|
||||||
|
<div className="entity-meta">
|
||||||
|
{formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown user'}
|
||||||
|
</div>
|
||||||
|
<div className="entity-meta">{entry.description || 'No description recorded.'}</div>
|
||||||
|
{entry.affectedUserEmail && (
|
||||||
|
<div className="entity-meta">Affected member: {entry.affectedUserEmail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="panel admin-list-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Households</h3>
|
||||||
|
<span className="subtle-text">{households.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{households.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No households were returned for this account yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="entity-list">
|
||||||
|
{households.map(household => {
|
||||||
|
const canManageHousehold = isSiteAdmin || household.isCurrentUserHouseholdAdmin
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`entity-row admin-household-row ${selectedHouseholdId === household.id ? 'is-selected' : ''}`}
|
||||||
|
key={household.id}
|
||||||
|
>
|
||||||
|
<div className="entity-copy admin-household-copy">
|
||||||
|
<div className="admin-household-header">
|
||||||
|
<strong>{household.name}</strong>
|
||||||
|
<div className="admin-badge-row">
|
||||||
|
{household.isCurrentUserHouseholdAdmin && <span className="admin-badge">Household admin</span>}
|
||||||
|
{household.adminEmail && <span className="member-badge">Owner: {household.adminEmail}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-meta">{household.description || 'No description provided.'}</div>
|
||||||
|
<div className="entity-meta">Created: {formatDate(household.createdAt) || 'Not available'}</div>
|
||||||
|
|
||||||
|
<div className="member-list">
|
||||||
|
{(household.members ?? []).map(member => (
|
||||||
|
<div className="member-row" key={member.userId}>
|
||||||
|
<div className="member-copy">
|
||||||
|
<strong>{formatPersonName(member)}</strong>
|
||||||
|
<span className="entity-meta">{member.email}</span>
|
||||||
|
</div>
|
||||||
|
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setSelectedHouseholdId(household.id)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
{canManageHousehold && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedHouseholdId(household.id)
|
||||||
|
setEditingHouseholdId(household.id)
|
||||||
|
setHouseholdForm({
|
||||||
|
name: household.name ?? '',
|
||||||
|
description: household.description ?? '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => handleLeaveHousehold(household.id)}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminPage
|
||||||
349
src/pages/BarcodePage/BarcodePage.css
Normal file
349
src/pages/BarcodePage/BarcodePage.css
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
.barcode-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-workspace,
|
||||||
|
.barcode-management-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-management-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-panel,
|
||||||
|
.scan-summary-panel,
|
||||||
|
.editor-panel,
|
||||||
|
.match-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-header p,
|
||||||
|
.scan-summary-copy p,
|
||||||
|
.manual-entry-note,
|
||||||
|
.editor-empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--button-primary-bg);
|
||||||
|
border-color: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-frame {
|
||||||
|
position: relative;
|
||||||
|
min-height: 380px;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 35%),
|
||||||
|
linear-gradient(135deg, var(--color-surface-subtle), var(--color-surface-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-frame.is-active {
|
||||||
|
background: var(--color-video-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-target,
|
||||||
|
.camera-target video,
|
||||||
|
.camera-target canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-target video,
|
||||||
|
.camera-target canvas {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-placeholder strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-window {
|
||||||
|
position: absolute;
|
||||||
|
inset: 16% 12%;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 0 0 999px rgba(15, 23, 42, 0.22);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-window::before,
|
||||||
|
.scan-window::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-window::before {
|
||||||
|
top: -2px;
|
||||||
|
left: -2px;
|
||||||
|
border-width: 4px 0 0 4px;
|
||||||
|
border-radius: 22px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-window::after {
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
border-width: 0 4px 4px 0;
|
||||||
|
border-radius: 0 0 22px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls,
|
||||||
|
.manual-entry-actions,
|
||||||
|
.quick-actions,
|
||||||
|
.lookup-result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manual-entry-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-input {
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(76, 175, 80, 0.18), transparent 32%),
|
||||||
|
linear-gradient(180deg, var(--color-surface-subtle), var(--color-surface));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-summary-label {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-summary-value {
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-summary-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-chip--barcode {
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-chip.is-success {
|
||||||
|
background: var(--status-success-bg);
|
||||||
|
border-color: var(--status-success-border);
|
||||||
|
color: var(--status-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-chip.is-warning {
|
||||||
|
background: var(--status-warning-bg);
|
||||||
|
border-color: var(--status-warning-border);
|
||||||
|
color: var(--status-warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-chip.is-neutral {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border-color: var(--color-border-muted);
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-results-grid,
|
||||||
|
.match-candidate-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-results-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-result-card,
|
||||||
|
.match-candidate-row,
|
||||||
|
.editor-empty-state {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-result-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-result-card.is-selected {
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-result-meta {
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty-state {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-search-field {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-candidate-row {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-candidate-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.barcode-workspace,
|
||||||
|
.barcode-management-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.scanner-header,
|
||||||
|
.camera-toolbar,
|
||||||
|
.match-candidate-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-frame {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-window {
|
||||||
|
inset: 20% 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls .btn,
|
||||||
|
.manual-entry-actions .btn,
|
||||||
|
.quick-actions .btn,
|
||||||
|
.lookup-result-actions .btn,
|
||||||
|
.match-candidate-row .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
891
src/pages/BarcodePage/BarcodePage.jsx
Normal file
891
src/pages/BarcodePage/BarcodePage.jsx
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import Quagga from '@ericblade/quagga2'
|
||||||
|
import {
|
||||||
|
inventoryApi,
|
||||||
|
locationsApi,
|
||||||
|
} from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import {
|
||||||
|
buildInventoryPayload,
|
||||||
|
createItemForm,
|
||||||
|
findItemsByBarcode,
|
||||||
|
mapItemToForm,
|
||||||
|
normalizeBarcode,
|
||||||
|
} from '../../utils/inventoryItemUtils.js'
|
||||||
|
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
||||||
|
import './BarcodePage.css'
|
||||||
|
|
||||||
|
const MATCH_CANDIDATE_LIMIT = 10
|
||||||
|
const CAMERA_READERS = [
|
||||||
|
'ean_reader',
|
||||||
|
'ean_8_reader',
|
||||||
|
'upc_reader',
|
||||||
|
'upc_e_reader',
|
||||||
|
'code_128_reader',
|
||||||
|
'code_39_reader',
|
||||||
|
]
|
||||||
|
|
||||||
|
function getScanSourceLabel(source) {
|
||||||
|
if (source === 'camera') return 'Camera'
|
||||||
|
if (source === 'hardware-scanner') return 'Scanner'
|
||||||
|
if (source === 'keyboard-paste') return 'Paste'
|
||||||
|
return 'Manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchCandidates(items, query, scannedBarcode) {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
const normalizedBarcode = normalizeBarcode(scannedBarcode)
|
||||||
|
|
||||||
|
return [...items]
|
||||||
|
.filter(item => normalizeBarcode(item.barcode) !== normalizedBarcode)
|
||||||
|
.sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''))
|
||||||
|
.filter(item => {
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableText = [
|
||||||
|
item.name ?? '',
|
||||||
|
item.location?.name ?? '',
|
||||||
|
item.barcode ?? '',
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
|
||||||
|
return searchableText.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
.slice(0, MATCH_CANDIDATE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarcodePage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [mode, setMode] = useState('camera')
|
||||||
|
const [cameraActive, setCameraActive] = useState(false)
|
||||||
|
const [manualBarcodeInput, setManualBarcodeInput] = useState('')
|
||||||
|
const [inventoryLoading, setInventoryLoading] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState(null)
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [lastScannedBarcode, setLastScannedBarcode] = useState('')
|
||||||
|
const [lastScanSource, setLastScanSource] = useState('')
|
||||||
|
const [locations, setLocations] = useState([])
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [editorMode, setEditorMode] = useState('')
|
||||||
|
const [editingItemId, setEditingItemId] = useState('')
|
||||||
|
const [itemForm, setItemForm] = useState(createItemForm())
|
||||||
|
const [matchQuery, setMatchQuery] = useState('')
|
||||||
|
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const scannerTargetRef = useRef(null)
|
||||||
|
const detectedHandlerRef = useRef(null)
|
||||||
|
const lastCameraScanRef = useRef({ barcode: '', at: 0 })
|
||||||
|
|
||||||
|
const matchingItems = findItemsByBarcode(items, lastScannedBarcode)
|
||||||
|
const matchCandidates = getMatchCandidates(items, matchQuery, lastScannedBarcode)
|
||||||
|
const hasScannedBarcode = Boolean(normalizeBarcode(lastScannedBarcode))
|
||||||
|
const hasExactMatches = matchingItems.length > 0
|
||||||
|
const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
|
||||||
|
|
||||||
|
const fetchInventoryData = useCallback(async () => {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { nextLocations, nextItems }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyInventoryData = useCallback(({ nextLocations, nextItems }) => {
|
||||||
|
setLocations(nextLocations)
|
||||||
|
setItems(nextItems)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshInventoryData = useCallback(async () => {
|
||||||
|
const nextData = await fetchInventoryData()
|
||||||
|
applyInventoryData(nextData)
|
||||||
|
return nextData
|
||||||
|
}, [applyInventoryData, fetchInventoryData])
|
||||||
|
|
||||||
|
const clearScannerTarget = useCallback(() => {
|
||||||
|
const target = scannerTargetRef.current
|
||||||
|
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
while (target.firstChild) {
|
||||||
|
target.removeChild(target.firstChild)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopCamera = useCallback(() => {
|
||||||
|
if (detectedHandlerRef.current) {
|
||||||
|
try { Quagga.offDetected(detectedHandlerRef.current) } catch (error) { /* ignore */ }
|
||||||
|
detectedHandlerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try { Quagga.stop() } catch (error) { /* ignore */ }
|
||||||
|
lastCameraScanRef.current = { barcode: '', at: 0 }
|
||||||
|
clearScannerTarget()
|
||||||
|
setCameraActive(false)
|
||||||
|
}, [clearScannerTarget])
|
||||||
|
|
||||||
|
const onBarcodeScanned = useCallback((barcode, inputType) => {
|
||||||
|
const normalizedBarcode = normalizeBarcode(barcode)
|
||||||
|
|
||||||
|
if (!normalizedBarcode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastScannedBarcode(normalizedBarcode)
|
||||||
|
setLastScanSource(inputType)
|
||||||
|
setManualBarcodeInput(normalizedBarcode)
|
||||||
|
setEditingItemId('')
|
||||||
|
setEditorMode('')
|
||||||
|
setItemForm(createItemForm(normalizedBarcode))
|
||||||
|
setMatchQuery('')
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'keyboard' && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
stopCamera()
|
||||||
|
setLocations([])
|
||||||
|
setItems([])
|
||||||
|
setLastScannedBarcode('')
|
||||||
|
setLastScanSource('')
|
||||||
|
setManualBarcodeInput('')
|
||||||
|
setEditorMode('')
|
||||||
|
setEditingItemId('')
|
||||||
|
setItemForm(createItemForm())
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setInventoryLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextData = await fetchInventoryData()
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
applyInventoryData(nextData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInventoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialData()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [applyInventoryData, fetchInventoryData, isAuthenticated, stopCamera])
|
||||||
|
|
||||||
|
const startCamera = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error('This browser does not support camera access for barcode scanning.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scannerTargetRef.current) {
|
||||||
|
throw new Error('The camera preview is not ready yet. Try again.')
|
||||||
|
}
|
||||||
|
|
||||||
|
stopCamera()
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
type: 'LiveStream',
|
||||||
|
target: scannerTargetRef.current,
|
||||||
|
constraints: {
|
||||||
|
facingMode: { ideal: 'environment' },
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
},
|
||||||
|
willReadFrequently: true,
|
||||||
|
},
|
||||||
|
locator: {
|
||||||
|
halfSample: true,
|
||||||
|
patchSize: 'medium',
|
||||||
|
},
|
||||||
|
numOfWorkers: navigator.hardwareConcurrency ? Math.min(4, navigator.hardwareConcurrency) : 2,
|
||||||
|
locate: true,
|
||||||
|
frequency: 10,
|
||||||
|
decoder: {
|
||||||
|
readers: CAMERA_READERS,
|
||||||
|
},
|
||||||
|
}, error => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const detectedHandler = result => {
|
||||||
|
const barcode = result?.codeResult?.code
|
||||||
|
|
||||||
|
if (!barcode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const previousScan = lastCameraScanRef.current
|
||||||
|
|
||||||
|
if (previousScan.barcode === barcode && (now - previousScan.at) < 1500) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCameraScanRef.current = { barcode, at: now }
|
||||||
|
onBarcodeScanned(barcode, 'camera')
|
||||||
|
}
|
||||||
|
|
||||||
|
detectedHandlerRef.current = detectedHandler
|
||||||
|
Quagga.onDetected(detectedHandler)
|
||||||
|
Quagga.start()
|
||||||
|
setCameraActive(true)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
stopCamera()
|
||||||
|
}
|
||||||
|
}, [onBarcodeScanned, stopCamera])
|
||||||
|
|
||||||
|
function switchToKeyboard() {
|
||||||
|
if (cameraActive) stopCamera()
|
||||||
|
setMode('keyboard')
|
||||||
|
setErrorMessage(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToCamera() {
|
||||||
|
setMode('camera')
|
||||||
|
setErrorMessage(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera()
|
||||||
|
}
|
||||||
|
}, [stopCamera])
|
||||||
|
|
||||||
|
function clearScan() {
|
||||||
|
setLastScannedBarcode('')
|
||||||
|
setLastScanSource('')
|
||||||
|
setManualBarcodeInput('')
|
||||||
|
setEditorMode('')
|
||||||
|
setEditingItemId('')
|
||||||
|
setItemForm(createItemForm())
|
||||||
|
setMatchQuery('')
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
lastCameraScanRef.current = { barcode: '', at: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateForm() {
|
||||||
|
setEditorMode('create')
|
||||||
|
setEditingItemId('')
|
||||||
|
setItemForm(createItemForm(lastScannedBarcode))
|
||||||
|
setStatusMessage('')
|
||||||
|
setErrorMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuickAdd() {
|
||||||
|
if (!hasScannedBarcode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quickAddSourceItem) {
|
||||||
|
openCreateForm()
|
||||||
|
setStatusMessage('No exact match found. Complete the form to add a new item.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdItem = await inventoryApi.createInventoryItem(buildInventoryPayload({
|
||||||
|
...mapItemToForm(quickAddSourceItem),
|
||||||
|
expiryDate: '',
|
||||||
|
useByDate: '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
await refreshInventoryData()
|
||||||
|
|
||||||
|
if (createdItem?.id) {
|
||||||
|
setStatusMessage(`Quick added ${quickAddSourceItem.name || 'item'} without expiry or use by dates.`)
|
||||||
|
} else {
|
||||||
|
setStatusMessage('Quick add completed without expiry or use by dates.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshInventory() {
|
||||||
|
setInventoryLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshInventoryData()
|
||||||
|
setStatusMessage('Inventory refreshed.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setInventoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditItem(itemId) {
|
||||||
|
setSubmitting(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
setEditingItemId(item.id)
|
||||||
|
setEditorMode('update')
|
||||||
|
setItemForm(mapItemToForm(item))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleItemSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = itemForm.name.trim()
|
||||||
|
const barcode = itemForm.barcode.trim()
|
||||||
|
|
||||||
|
if (!name && !barcode) {
|
||||||
|
setErrorMessage('Provide an item name or a barcode.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
let itemId = editingItemId
|
||||||
|
|
||||||
|
if (editorMode === 'update' && editingItemId) {
|
||||||
|
await inventoryApi.updateInventoryItem(editingItemId, buildInventoryPayload(itemForm, true))
|
||||||
|
setStatusMessage('Inventory item updated.')
|
||||||
|
} else {
|
||||||
|
const createdItem = await inventoryApi.createInventoryItem(buildInventoryPayload(itemForm))
|
||||||
|
itemId = createdItem.id
|
||||||
|
setStatusMessage('Inventory item created.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshInventoryData()
|
||||||
|
|
||||||
|
if (itemId) {
|
||||||
|
const freshItem = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
const savedBarcode = normalizeBarcode(freshItem.barcode)
|
||||||
|
|
||||||
|
setEditingItemId(freshItem.id)
|
||||||
|
setEditorMode('update')
|
||||||
|
setItemForm(mapItemToForm(freshItem))
|
||||||
|
|
||||||
|
if (savedBarcode) {
|
||||||
|
setLastScannedBarcode(savedBarcode)
|
||||||
|
setManualBarcodeInput(savedBarcode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMatchItem(itemId, itemName, currentBarcode) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
currentBarcode
|
||||||
|
? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?`
|
||||||
|
: `Assign barcode ${lastScannedBarcode} to ${itemName}?`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
|
||||||
|
await inventoryApi.updateInventoryItem(itemId, buildInventoryPayload({
|
||||||
|
...mapItemToForm(item),
|
||||||
|
barcode: lastScannedBarcode,
|
||||||
|
}, true))
|
||||||
|
|
||||||
|
await refreshInventoryData()
|
||||||
|
|
||||||
|
const freshItem = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
setEditingItemId(freshItem.id)
|
||||||
|
setEditorMode('update')
|
||||||
|
setItemForm(mapItemToForm(freshItem))
|
||||||
|
setStatusMessage(`Barcode ${lastScannedBarcode} matched to ${freshItem.name}.`)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Barcode Scanner</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before scanning against your inventory.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="barcode-page">
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{inventoryLoading && <div className="status-banner status-banner--info">Refreshing your inventory...</div>}
|
||||||
|
{submitting && <div className="status-banner status-banner--info">Saving your barcode workflow...</div>}
|
||||||
|
|
||||||
|
<div className="barcode-workspace">
|
||||||
|
<section className="panel scanner-panel">
|
||||||
|
<div className="scanner-header">
|
||||||
|
<div className="scan-summary-meta">
|
||||||
|
{hasScannedBarcode && (
|
||||||
|
<span className="scan-chip is-neutral scan-chip--barcode">
|
||||||
|
{lastScannedBarcode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`scan-chip ${hasExactMatches ? 'is-success' : hasScannedBarcode ? 'is-warning' : 'is-neutral'}`}>
|
||||||
|
{hasScannedBarcode ? (hasExactMatches ? `${matchingItems.length} match${matchingItems.length === 1 ? '' : 'es'}` : 'No exact matches') : 'Ready to scan'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'camera' ? (
|
||||||
|
<>
|
||||||
|
<div className={`camera-frame ${cameraActive ? 'is-active' : ''}`}>
|
||||||
|
<div ref={scannerTargetRef} className="camera-target" />
|
||||||
|
|
||||||
|
{!cameraActive && (
|
||||||
|
<div className="camera-placeholder">
|
||||||
|
<strong>Ready to scan</strong>
|
||||||
|
<span>Use the rear camera when available. Place the barcode inside the guide.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="camera-overlay" aria-hidden="true">
|
||||||
|
<div className="scan-window" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="camera-toolbar">
|
||||||
|
<div className="camera-controls">
|
||||||
|
{!cameraActive ? (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={startCamera}>
|
||||||
|
Start camera
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={stopCamera}>
|
||||||
|
Stop camera
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleRefreshInventory}
|
||||||
|
disabled={inventoryLoading || submitting}
|
||||||
|
>
|
||||||
|
Refresh inventory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="manual-entry-panel">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="barcode-input">Barcode input</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="barcode-input"
|
||||||
|
type="text"
|
||||||
|
className="barcode-input"
|
||||||
|
value={manualBarcodeInput}
|
||||||
|
onChange={event => setManualBarcodeInput(event.target.value)}
|
||||||
|
onPaste={event => {
|
||||||
|
const pastedText = event.clipboardData.getData('text')
|
||||||
|
|
||||||
|
if (!pastedText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setManualBarcodeInput(pastedText)
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== 'Enter') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
onBarcodeScanned(manualBarcodeInput, 'hardware-scanner')
|
||||||
|
}}
|
||||||
|
placeholder="Scan, type, or paste a barcode"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="manual-entry-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => onBarcodeScanned(manualBarcodeInput, 'manual')}
|
||||||
|
>
|
||||||
|
Check barcode
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setManualBarcodeInput('')
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear input
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="manual-entry-note">
|
||||||
|
Hardware scanners that act like keyboards can scan directly into this field and submit with Enter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="quick-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
disabled={!hasScannedBarcode || submitting || inventoryLoading}
|
||||||
|
>
|
||||||
|
Quick Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* <section className="panel scan-summary-panel">
|
||||||
|
<div className="scan-summary-card">
|
||||||
|
<span className="scan-summary-label">Last scanned barcode</span>
|
||||||
|
<strong className="scan-summary-value">{hasScannedBarcode ? lastScannedBarcode : 'Waiting for a scan'}</strong>
|
||||||
|
<div className="scan-summary-meta">
|
||||||
|
<span className={`scan-chip ${hasExactMatches ? 'is-success' : hasScannedBarcode ? 'is-warning' : 'is-neutral'}`}>
|
||||||
|
{hasScannedBarcode ? (hasExactMatches ? `${matchingItems.length} match${matchingItems.length === 1 ? '' : 'es'}` : 'No exact matches') : 'No barcode yet'}
|
||||||
|
</span>
|
||||||
|
{lastScanSource && <span className="scan-chip is-neutral">{getScanSourceLabel(lastScanSource)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scan-summary-copy">
|
||||||
|
{!hasScannedBarcode ? (
|
||||||
|
<p>Scan a barcode to review exact inventory matches, open an update form, or create a new item prefilled with the captured barcode.</p>
|
||||||
|
) : hasExactMatches ? (
|
||||||
|
<p>Found {matchingItems.length} exact match{matchingItems.length === 1 ? '' : 'es'} in your inventory for barcode {lastScannedBarcode}.</p>
|
||||||
|
) : (
|
||||||
|
<p>No exact match exists in your inventory for barcode {lastScannedBarcode}. Add a new item or match this barcode to an existing one.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={openCreateForm}
|
||||||
|
disabled={!hasScannedBarcode || submitting}
|
||||||
|
>
|
||||||
|
Add new item
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={clearScan}
|
||||||
|
disabled={!hasScannedBarcode && !manualBarcodeInput}
|
||||||
|
>
|
||||||
|
Clear scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="barcode-management-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Inventory matches</h3>
|
||||||
|
{hasScannedBarcode && (
|
||||||
|
<span className="subtle-text">{matchingItems.length} exact match{matchingItems.length === 1 ? '' : 'es'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasScannedBarcode ? (
|
||||||
|
<div className="empty-state compact-empty-state">Scan a barcode to search your inventory.</div>
|
||||||
|
) : hasExactMatches ? (
|
||||||
|
<div className="lookup-results-grid">
|
||||||
|
{matchingItems.map(item => (
|
||||||
|
<article className={`lookup-result-card ${editingItemId === item.id ? 'is-selected' : ''}`} key={item.id}>
|
||||||
|
<div className="lookup-result-header">
|
||||||
|
<div>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="lookup-result-meta">{item.location?.name || 'No location'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="scan-chip is-success">Match</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lookup-result-meta">Amount: {formatAmount(item.amount, item.amountType)}</div>
|
||||||
|
<div className="lookup-result-meta">Expiry: {formatDate(item.expiryDate) || 'Not set'}</div>
|
||||||
|
<div className="lookup-result-meta">Use by: {formatDate(item.useByDate) || 'Not set'}</div>
|
||||||
|
<div className="lookup-result-meta">Barcode: {item.barcode || 'Not set'}</div>
|
||||||
|
|
||||||
|
<div className="lookup-result-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleEditItem(item.id)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Update item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state compact-empty-state">
|
||||||
|
No items in your inventory currently use barcode {lastScannedBarcode}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel editor-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editorMode === 'update' ? 'Update item' : 'Add item'}</h3>
|
||||||
|
{editorMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditorMode('')
|
||||||
|
setEditingItemId('')
|
||||||
|
setItemForm(createItemForm(lastScannedBarcode))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close editor
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editorMode ? (
|
||||||
|
<div className="editor-empty-state">
|
||||||
|
<p>Use Quick Add to clone an exact barcode match without expiry dates, or to open the create form when there is no exact match. Use Update item on one of the exact matches to edit it here.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="editor-form" onSubmit={handleItemSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="item-name"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.name}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="Whole Milk"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-barcode">Barcode</label>
|
||||||
|
<input
|
||||||
|
id="item-barcode"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.barcode}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, barcode: event.target.value }))}
|
||||||
|
placeholder="01234567890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-location">Location</label>
|
||||||
|
<select
|
||||||
|
id="item-location"
|
||||||
|
value={itemForm.locationId}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, locationId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">No location</option>
|
||||||
|
{locations.map(location => (
|
||||||
|
<option key={location.id} value={location.id}>{location.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-amount">Amount</label>
|
||||||
|
<input
|
||||||
|
id="item-amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={itemForm.amount}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, amount: event.target.value }))}
|
||||||
|
placeholder="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-amount-type">Amount type</label>
|
||||||
|
<input
|
||||||
|
id="item-amount-type"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.amountType}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, amountType: event.target.value }))}
|
||||||
|
placeholder="litres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-expiry-date">Expiry date</label>
|
||||||
|
<input
|
||||||
|
id="item-expiry-date"
|
||||||
|
type="date"
|
||||||
|
value={itemForm.expiryDate}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, expiryDate: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-use-by-date">Use by date</label>
|
||||||
|
<input
|
||||||
|
id="item-use-by-date"
|
||||||
|
type="date"
|
||||||
|
value={itemForm.useByDate}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, useByDate: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{editorMode === 'update' ? 'Save changes' : 'Create item'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (editorMode === 'update' && editingItemId) {
|
||||||
|
handleEditItem(editingItemId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemForm(createItemForm(lastScannedBarcode))
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorMode === 'update' && (
|
||||||
|
<p className="form-note">
|
||||||
|
Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editorMode !== 'update' && (
|
||||||
|
<p className="form-note">
|
||||||
|
If the name is blank but the barcode resolves through the API lookup, the item name can be filled automatically.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasScannedBarcode && !hasExactMatches && (
|
||||||
|
<section className="panel match-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Match barcode to an existing item</h3>
|
||||||
|
<span className="subtle-text">Assign {lastScannedBarcode} to an existing inventory record</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group match-search-field">
|
||||||
|
<label htmlFor="match-query">Find an item</label>
|
||||||
|
<input
|
||||||
|
id="match-query"
|
||||||
|
type="text"
|
||||||
|
value={matchQuery}
|
||||||
|
onChange={event => setMatchQuery(event.target.value)}
|
||||||
|
placeholder="Search by item name, location, or current barcode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchCandidates.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No inventory items match that filter.</div>
|
||||||
|
) : (
|
||||||
|
<div className="match-candidate-list">
|
||||||
|
{matchCandidates.map(item => (
|
||||||
|
<div className="match-candidate-row" key={item.id}>
|
||||||
|
<div className="match-candidate-copy">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="entity-meta">{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</div>
|
||||||
|
<div className="entity-meta">Current barcode: {item.barcode || 'Not set'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleMatchItem(item.id, item.name, item.barcode)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Match barcode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarcodePage
|
||||||
73
src/pages/HomePage/HomePage.css
Normal file
73
src/pages/HomePage/HomePage.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid {
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.chart-panel {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-column-grid,
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/pages/HomePage/HomePage.jsx
Normal file
223
src/pages/HomePage/HomePage.jsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import PieChart from '../../components/PieChart/PieChart.jsx'
|
||||||
|
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||||
|
import { inventoryApi, locationsApi } from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
|
||||||
|
import './HomePage.css'
|
||||||
|
|
||||||
|
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
|
||||||
|
|
||||||
|
const INITIAL_LOGIN_FORM = {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieSegments(items, locations) {
|
||||||
|
const counts = new Map(locations.map(location => [location.name, 0]))
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const locationName = item.location?.name || 'Unassigned'
|
||||||
|
counts.set(locationName, (counts.get(locationName) ?? 0) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(counts.entries())
|
||||||
|
.filter(([, value]) => value > 0)
|
||||||
|
.map(([label, value], index) => ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color: PIE_COLORS[index % PIE_COLORS.length],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByExpiry(items) {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||||
|
const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||||
|
return leftDate - rightDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
const { initializing, isAuthenticated, login } = useAuth()
|
||||||
|
const [loginForm, setLoginForm] = useState(INITIAL_LOGIN_FORM)
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
|
const [formStatus, setFormStatus] = useState('')
|
||||||
|
const [dashboardError, setDashboardError] = useState('')
|
||||||
|
const [dashboardLoading, setDashboardLoading] = useState(false)
|
||||||
|
const [locations, setLocations] = useState([])
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setLocations([])
|
||||||
|
setItems([])
|
||||||
|
setDashboardError('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDashboardLoading(true)
|
||||||
|
setDashboardError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocations(nextLocations)
|
||||||
|
setItems(nextItems)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDashboardError(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDashboardLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboard()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
async function handleLoginSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setFormError('')
|
||||||
|
setFormStatus('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({
|
||||||
|
email: loginForm.email.trim(),
|
||||||
|
password: loginForm.password,
|
||||||
|
})
|
||||||
|
setFormStatus('Signed in successfully.')
|
||||||
|
setLoginForm(INITIAL_LOGIN_FORM)
|
||||||
|
} catch (error) {
|
||||||
|
setFormError(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieSegments = buildPieSegments(items, locations)
|
||||||
|
const expiringItems = sortByExpiry(items)
|
||||||
|
.filter(item => item.expiryDate)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
const isLoginView = !isAuthenticated
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isLoginView && (
|
||||||
|
<>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<hr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initializing && <div className="status-banner status-banner--info">Restoring your session...</div>}
|
||||||
|
{formError && <div className="status-banner status-banner--error">{formError}</div>}
|
||||||
|
{formStatus && <div className="status-banner status-banner--success">{formStatus}</div>}
|
||||||
|
{dashboardLoading && !isLoginView && <div className="status-banner status-banner--info">Loading dashboard data...</div>}
|
||||||
|
{dashboardError && <div className="status-banner status-banner--error">{dashboardError}</div>}
|
||||||
|
|
||||||
|
{isLoginView ? (
|
||||||
|
<div className="dashboard-grid auth-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<h3>Login</h3>
|
||||||
|
<form className="stack-form" onSubmit={handleLoginSubmit}>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="login-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
value={loginForm.email}
|
||||||
|
onChange={event => setLoginForm(current => ({ ...current, email: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="login-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
value={loginForm.password}
|
||||||
|
onChange={event => setLoginForm(current => ({ ...current, password: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-grid">
|
||||||
|
<section className="panel stats-panel">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Locations</span>
|
||||||
|
<strong>{locations.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Inventory Items</span>
|
||||||
|
<strong>{items.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span className="stat-label">Expiring Soon</span>
|
||||||
|
<strong>{items.filter(item => {
|
||||||
|
const status = getExpiryStatus(item.expiryDate)
|
||||||
|
return status.status === 'Soon' || status.status === 'Today'
|
||||||
|
}).length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel chart-panel">
|
||||||
|
<h3>Inventory Breakdown</h3>
|
||||||
|
{pieSegments.length > 0 ? (
|
||||||
|
<PieChart segments={pieSegments} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-state compact-empty-state">Create some inventory items to populate the chart.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel full-width-panel">
|
||||||
|
<h3>Nearest Expiry Dates</h3>
|
||||||
|
{expiringItems.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No dated inventory items yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="item-component-grid">
|
||||||
|
{expiringItems.map(item => {
|
||||||
|
const expiryStatus = getExpiryStatus(item.expiryDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
text1={item.name}
|
||||||
|
text2={`${item.location?.name || 'No location'} | ${formatAmount(item.amount, item.amountType)}`}
|
||||||
|
text3={`${formatDate(item.expiryDate)} | ${expiryStatus.text}`}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
89
src/pages/InventoryPage/InventoryPage.css
Normal file
89
src/pages/InventoryPage/InventoryPage.css
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
.management-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-row.is-selected {
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-detail-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-list-panel {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-empty-state {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.entity-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
638
src/pages/InventoryPage/InventoryPage.jsx
Normal file
638
src/pages/InventoryPage/InventoryPage.jsx
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
inventoryApi,
|
||||||
|
locationsApi,
|
||||||
|
} from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import {
|
||||||
|
buildInventoryPayload,
|
||||||
|
createItemForm,
|
||||||
|
mapItemToForm,
|
||||||
|
} from '../../utils/inventoryItemUtils.js'
|
||||||
|
import {
|
||||||
|
formatAmount,
|
||||||
|
formatDate,
|
||||||
|
getExpiryStatus,
|
||||||
|
} from '../../utils/searchUtils.js'
|
||||||
|
import './InventoryPage.css'
|
||||||
|
|
||||||
|
const EMPTY_LOCATION_FORM = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryPage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [locations, setLocations] = useState([])
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [selectedLocationId, setSelectedLocationId] = useState('')
|
||||||
|
const [locationHistory, setLocationHistory] = useState([])
|
||||||
|
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
|
||||||
|
const [locationHistoryError, setLocationHistoryError] = useState('')
|
||||||
|
const [editingLocationId, setEditingLocationId] = useState('')
|
||||||
|
const [editingItemId, setEditingItemId] = useState('')
|
||||||
|
const [selectedItem, setSelectedItem] = useState(null)
|
||||||
|
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
|
||||||
|
const [itemForm, setItemForm] = useState(createItemForm())
|
||||||
|
|
||||||
|
function syncLocations(nextLocations) {
|
||||||
|
setLocations(nextLocations)
|
||||||
|
setSelectedLocationId(currentLocationId => {
|
||||||
|
const targetLocationId = currentLocationId || editingLocationId
|
||||||
|
|
||||||
|
if (nextLocations.some(location => location.id === targetLocationId)) {
|
||||||
|
return targetLocationId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextLocations[0]?.id ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPageData() {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
syncLocations(nextLocations)
|
||||||
|
setItems(nextItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setLocations([])
|
||||||
|
setItems([])
|
||||||
|
setSelectedLocationId('')
|
||||||
|
setLocationHistory([])
|
||||||
|
setLocationHistoryLoading(false)
|
||||||
|
setLocationHistoryError('')
|
||||||
|
setSelectedItem(null)
|
||||||
|
setEditingItemId('')
|
||||||
|
setEditingLocationId('')
|
||||||
|
setLocationForm(EMPTY_LOCATION_FORM)
|
||||||
|
setItemForm(createItemForm())
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
syncLocations(nextLocations)
|
||||||
|
setItems(nextItems)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialData()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadSelectedLocationHistory() {
|
||||||
|
if (!isAuthenticated || !selectedLocationId) {
|
||||||
|
setLocationHistory([])
|
||||||
|
setLocationHistoryLoading(false)
|
||||||
|
setLocationHistoryError('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocationHistoryLoading(true)
|
||||||
|
setLocationHistoryError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await locationsApi.getLocationHistory(selectedLocationId)
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocationHistory(Array.isArray(response) ? response : [])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocationHistory([])
|
||||||
|
setLocationHistoryError(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocationHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSelectedLocationHistory()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, selectedLocationId])
|
||||||
|
|
||||||
|
const selectedLocation = locations.find(location => location.id === selectedLocationId) ?? null
|
||||||
|
|
||||||
|
function resetLocationEditor() {
|
||||||
|
setEditingLocationId('')
|
||||||
|
setLocationForm(EMPTY_LOCATION_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetItemEditor() {
|
||||||
|
setEditingItemId('')
|
||||||
|
setSelectedItem(null)
|
||||||
|
setItemForm(createItemForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLocationSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = locationForm.name.trim()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setErrorMessage('Location name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description: locationForm.description.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingLocationId) {
|
||||||
|
await locationsApi.updateLocation(editingLocationId, payload)
|
||||||
|
setStatusMessage('Location updated.')
|
||||||
|
} else {
|
||||||
|
await locationsApi.createLocation(payload)
|
||||||
|
setStatusMessage('Location created.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPageData()
|
||||||
|
resetLocationEditor()
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteLocation(locationId) {
|
||||||
|
const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.')
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await locationsApi.deleteLocation(locationId)
|
||||||
|
await loadPageData()
|
||||||
|
|
||||||
|
if (editingLocationId === locationId) {
|
||||||
|
resetLocationEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setItemForm(currentForm => (
|
||||||
|
currentForm.locationId === locationId
|
||||||
|
? { ...currentForm, locationId: '' }
|
||||||
|
: currentForm
|
||||||
|
))
|
||||||
|
|
||||||
|
setStatusMessage('Location deleted.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditItem(itemId) {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
setSelectedItem(item)
|
||||||
|
setEditingItemId(item.id)
|
||||||
|
setItemForm(mapItemToForm(item))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleItemSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = itemForm.name.trim()
|
||||||
|
const barcode = itemForm.barcode.trim()
|
||||||
|
|
||||||
|
if (!name && !barcode) {
|
||||||
|
setErrorMessage('Provide an item name or a barcode.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
let itemId = editingItemId
|
||||||
|
|
||||||
|
if (editingItemId) {
|
||||||
|
await inventoryApi.updateInventoryItem(editingItemId, buildInventoryPayload(itemForm, true))
|
||||||
|
setStatusMessage('Inventory item updated.')
|
||||||
|
} else {
|
||||||
|
const created = await inventoryApi.createInventoryItem(buildInventoryPayload(itemForm))
|
||||||
|
itemId = created.id
|
||||||
|
setStatusMessage('Inventory item created.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPageData()
|
||||||
|
|
||||||
|
if (itemId) {
|
||||||
|
const freshItem = await inventoryApi.getInventoryItem(itemId)
|
||||||
|
setSelectedItem(freshItem)
|
||||||
|
setEditingItemId(freshItem.id)
|
||||||
|
setItemForm(mapItemToForm(freshItem))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteItem(itemId) {
|
||||||
|
const confirmed = window.confirm('Delete this inventory item?')
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await inventoryApi.deleteInventoryItem(itemId)
|
||||||
|
await loadPageData()
|
||||||
|
|
||||||
|
if (editingItemId === itemId) {
|
||||||
|
resetItemEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Inventory item deleted.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Inventory</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before using the protected inventory and location endpoints.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Syncing with the API...</div>}
|
||||||
|
|
||||||
|
<div className="management-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Locations</h3>
|
||||||
|
{editingLocationId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetLocationEditor}>
|
||||||
|
New location
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleLocationSubmit}>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="location-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="location-name"
|
||||||
|
type="text"
|
||||||
|
value={locationForm.name}
|
||||||
|
onChange={event => setLocationForm(current => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="Pantry"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="location-description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="location-description"
|
||||||
|
rows="3"
|
||||||
|
value={locationForm.description}
|
||||||
|
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
|
||||||
|
placeholder="Main kitchen shelf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingLocationId ? 'Update location' : 'Create location'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetLocationEditor}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="entity-list">
|
||||||
|
{locations.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No locations yet.</div>
|
||||||
|
) : (
|
||||||
|
locations.map(location => (
|
||||||
|
<div
|
||||||
|
className={`entity-row ${selectedLocationId === location.id || editingLocationId === location.id ? 'is-selected' : ''}`}
|
||||||
|
key={location.id}
|
||||||
|
>
|
||||||
|
<div className="entity-copy">
|
||||||
|
<strong>{location.name}</strong>
|
||||||
|
<div className="entity-meta">{location.description || 'No description provided.'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLocationId(location.id)
|
||||||
|
setEditingLocationId(location.id)
|
||||||
|
setLocationForm({
|
||||||
|
name: location.name,
|
||||||
|
description: location.description ?? '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setSelectedLocationId(location.id)}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => handleDeleteLocation(location.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Location History</h3>
|
||||||
|
<span className="subtle-text">{selectedLocation?.name || 'No location selected'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locationHistoryLoading && (
|
||||||
|
<div className="status-banner status-banner--info">Loading location history...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationHistoryError && (
|
||||||
|
<div className="status-banner status-banner--error">{locationHistoryError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedLocation ? (
|
||||||
|
<div className="empty-state compact-empty-state">Select a location to review its audit history.</div>
|
||||||
|
) : locationHistory.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No location history was returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="entity-list">
|
||||||
|
{locationHistory.map(entry => (
|
||||||
|
<article className="entity-row" key={entry.id}>
|
||||||
|
<div className="entity-copy">
|
||||||
|
<strong>{entry.action}</strong>
|
||||||
|
<div className="entity-meta">
|
||||||
|
{formatDate(entry.changedAt) || 'Unknown date'} by {entry.changedByEmail || 'Unknown user'}
|
||||||
|
</div>
|
||||||
|
<div className="entity-meta">{entry.description || 'No description recorded.'}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editingItemId ? 'Edit Item' : 'Add Item'}</h3>
|
||||||
|
{editingItemId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetItemEditor}>
|
||||||
|
New item
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleItemSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="item-name"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.name}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, name: event.target.value }))}
|
||||||
|
placeholder="Whole Milk"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-barcode">Barcode</label>
|
||||||
|
<input
|
||||||
|
id="item-barcode"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.barcode}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, barcode: event.target.value }))}
|
||||||
|
placeholder="01234567890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-location">Location</label>
|
||||||
|
<select
|
||||||
|
id="item-location"
|
||||||
|
value={itemForm.locationId}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, locationId: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">No location</option>
|
||||||
|
{locations.map(location => (
|
||||||
|
<option key={location.id} value={location.id}>{location.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-amount">Amount</label>
|
||||||
|
<input
|
||||||
|
id="item-amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={itemForm.amount}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, amount: event.target.value }))}
|
||||||
|
placeholder="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-amount-type">Amount Type</label>
|
||||||
|
<input
|
||||||
|
id="item-amount-type"
|
||||||
|
type="text"
|
||||||
|
value={itemForm.amountType}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, amountType: event.target.value }))}
|
||||||
|
placeholder="litres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-expiry-date">Expiry Date</label>
|
||||||
|
<input
|
||||||
|
id="item-expiry-date"
|
||||||
|
type="date"
|
||||||
|
value={itemForm.expiryDate}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, expiryDate: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="item-use-by-date">Use By Date</label>
|
||||||
|
<input
|
||||||
|
id="item-use-by-date"
|
||||||
|
type="date"
|
||||||
|
value={itemForm.useByDate}
|
||||||
|
onChange={event => setItemForm(current => ({ ...current, useByDate: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingItemId ? 'Update item' : 'Create item'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetItemEditor}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingItemId && (
|
||||||
|
<p className="form-note">
|
||||||
|
Blank text fields are sent as empty strings. Blank amount, date, and location values are skipped because the API only updates provided values.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editingItemId && (
|
||||||
|
<p className="form-note">
|
||||||
|
If the name is blank but the barcode matches a cached or external lookup, the API can fill the item name for you.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="item-detail-card">
|
||||||
|
<strong>Selected item</strong>
|
||||||
|
<div className="entity-meta">{selectedItem.name}</div>
|
||||||
|
<div className="entity-meta">{selectedItem.location?.name || 'No location'} | {formatAmount(selectedItem.amount, selectedItem.amountType)}</div>
|
||||||
|
<div className="entity-meta">Expiry: {formatDate(selectedItem.expiryDate) || 'Not set'}</div>
|
||||||
|
<div className="entity-meta">Use by: {formatDate(selectedItem.useByDate) || 'Not set'}</div>
|
||||||
|
<div className="entity-meta">Barcode: {selectedItem.barcode || 'Not set'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="panel inventory-list-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Inventory Items</h3>
|
||||||
|
<span className="subtle-text">{items.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-list">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No inventory items yet.</div>
|
||||||
|
) : (
|
||||||
|
items.map(item => {
|
||||||
|
const expiryStatus = getExpiryStatus(item.expiryDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`entity-row ${editingItemId === item.id ? 'is-selected' : ''}`}
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="entity-copy">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="entity-meta">{item.location?.name || 'No location'} | {formatAmount(item.amount, item.amountType)}</div>
|
||||||
|
<div className="entity-meta">Expiry: {formatDate(item.expiryDate) || 'Not set'} | {expiryStatus.text || 'No expiry date'}</div>
|
||||||
|
<div className="entity-meta">Barcode: {item.barcode || 'Not set'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => handleEditItem(item.id)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => handleDeleteItem(item.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventoryPage
|
||||||
597
src/pages/MealPlannersPage/MealPlannersPage.jsx
Normal file
597
src/pages/MealPlannersPage/MealPlannersPage.jsx
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
householdsApi,
|
||||||
|
inventoryApi,
|
||||||
|
mealPlannersApi,
|
||||||
|
} from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import {
|
||||||
|
formatAmount,
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
toDateInputValue,
|
||||||
|
toTimeInputValue,
|
||||||
|
} from '../../utils/searchUtils.js'
|
||||||
|
import '../PlanningPage.css'
|
||||||
|
|
||||||
|
function createMealPlannerItemForm() {
|
||||||
|
return {
|
||||||
|
inventoryItemId: '',
|
||||||
|
amountRequired: '',
|
||||||
|
amountType: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMealPlannerForm(householdId = '') {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
householdId,
|
||||||
|
plannedDate: '',
|
||||||
|
plannedTime: '',
|
||||||
|
items: [createMealPlannerItemForm()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMealPlannerToForm(mealPlanner) {
|
||||||
|
return {
|
||||||
|
name: mealPlanner?.name ?? '',
|
||||||
|
householdId: mealPlanner?.householdId ?? '',
|
||||||
|
plannedDate: toDateInputValue(mealPlanner?.plannedDate),
|
||||||
|
plannedTime: toTimeInputValue(mealPlanner?.plannedTime),
|
||||||
|
items: Array.isArray(mealPlanner?.items) && mealPlanner.items.length > 0
|
||||||
|
? mealPlanner.items.map(item => ({
|
||||||
|
inventoryItemId: item.inventoryItemId ?? '',
|
||||||
|
amountRequired: item.amountRequired == null ? '' : String(item.amountRequired),
|
||||||
|
amountType: item.amountType ?? '',
|
||||||
|
}))
|
||||||
|
: [createMealPlannerItemForm()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHouseholdName(households, householdId) {
|
||||||
|
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiTimeValue(timeValue) {
|
||||||
|
if (!timeValue) return ''
|
||||||
|
return timeValue.length === 5 ? `${timeValue}:00` : timeValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMealPlannerItems(items) {
|
||||||
|
const normalizedItems = items
|
||||||
|
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
|
||||||
|
.map((item, index) => {
|
||||||
|
const amountRequired = Number(item.amountRequired)
|
||||||
|
const amountType = item.amountType.trim()
|
||||||
|
|
||||||
|
if (!item.inventoryItemId) {
|
||||||
|
throw new Error(`Choose an inventory item for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountRequired) || amountRequired <= 0) {
|
||||||
|
throw new Error(`Amount required must be greater than zero for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amountType) {
|
||||||
|
throw new Error(`Amount type is required for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inventoryItemId: item.inventoryItemId,
|
||||||
|
amountRequired,
|
||||||
|
amountType,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const distinctIds = new Set(normalizedItems.map(item => item.inventoryItemId))
|
||||||
|
|
||||||
|
if (distinctIds.size !== normalizedItems.length) {
|
||||||
|
throw new Error('Each inventory item can only appear once in a meal planner.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
function MealPlannersPage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [households, setHouseholds] = useState([])
|
||||||
|
const [inventoryItems, setInventoryItems] = useState([])
|
||||||
|
const [mealPlanners, setMealPlanners] = useState([])
|
||||||
|
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
|
||||||
|
const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
|
||||||
|
const [mealPlannerForm, setMealPlannerForm] = useState(createMealPlannerForm())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setHouseholds([])
|
||||||
|
setInventoryItems([])
|
||||||
|
setMealPlanners([])
|
||||||
|
setSelectedMealPlannerId('')
|
||||||
|
setEditingMealPlannerId('')
|
||||||
|
setMealPlannerForm(createMealPlannerForm())
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
|
||||||
|
householdsApi.getHouseholds(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
mealPlannersApi.getMealPlanners(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
|
||||||
|
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
|
||||||
|
const nextMealPlanners = Array.isArray(nextMealPlannersResponse) ? nextMealPlannersResponse : []
|
||||||
|
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setInventoryItems(nextInventoryItems)
|
||||||
|
setMealPlanners(nextMealPlanners)
|
||||||
|
setSelectedMealPlannerId(nextMealPlanners[0]?.id ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialData()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingMealPlannerId || mealPlannerForm.householdId || households.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMealPlannerForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
householdId: households[0].id,
|
||||||
|
}))
|
||||||
|
}, [editingMealPlannerId, households, mealPlannerForm.householdId])
|
||||||
|
|
||||||
|
async function loadPageData(preferredSelectionId = '') {
|
||||||
|
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
|
||||||
|
householdsApi.getHouseholds(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
mealPlannersApi.getMealPlanners(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
|
||||||
|
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
|
||||||
|
const nextMealPlanners = Array.isArray(nextMealPlannersResponse) ? nextMealPlannersResponse : []
|
||||||
|
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setInventoryItems(nextInventoryItems)
|
||||||
|
setMealPlanners(nextMealPlanners)
|
||||||
|
setSelectedMealPlannerId(currentSelectionId => {
|
||||||
|
const targetSelectionId = preferredSelectionId || currentSelectionId
|
||||||
|
|
||||||
|
if (nextMealPlanners.some(mealPlanner => mealPlanner.id === targetSelectionId)) {
|
||||||
|
return targetSelectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMealPlanners[0]?.id ?? ''
|
||||||
|
})
|
||||||
|
setEditingMealPlannerId(currentEditingId => (
|
||||||
|
nextMealPlanners.some(mealPlanner => mealPlanner.id === currentEditingId)
|
||||||
|
? currentEditingId
|
||||||
|
: ''
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
setEditingMealPlannerId('')
|
||||||
|
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemRow(index, updates) {
|
||||||
|
setMealPlannerForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: currentForm.items.map((item, itemIndex) => (
|
||||||
|
itemIndex === index
|
||||||
|
? { ...item, ...updates }
|
||||||
|
: item
|
||||||
|
)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItemRow() {
|
||||||
|
setMealPlannerForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: [...currentForm.items, createMealPlannerItemForm()],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemRow(index) {
|
||||||
|
setMealPlannerForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditMealPlanner(mealPlannerId) {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mealPlanner = await mealPlannersApi.getMealPlanner(mealPlannerId)
|
||||||
|
setSelectedMealPlannerId(mealPlanner.id)
|
||||||
|
setEditingMealPlannerId(mealPlanner.id)
|
||||||
|
setMealPlannerForm(mapMealPlannerToForm(mealPlanner))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMealPlannerSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = mealPlannerForm.name.trim()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setErrorMessage('Meal planner name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingMealPlannerId && !mealPlannerForm.householdId) {
|
||||||
|
setErrorMessage('Choose a household before creating a meal planner.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mealPlannerForm.plannedDate) {
|
||||||
|
setErrorMessage('Choose a planned date.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mealPlannerForm.plannedTime) {
|
||||||
|
setErrorMessage('Choose a planned time.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedItems
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalizedItems = normalizeMealPlannerItems(mealPlannerForm.items)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
plannedDate: mealPlannerForm.plannedDate,
|
||||||
|
plannedTime: toApiTimeValue(mealPlannerForm.plannedTime),
|
||||||
|
items: normalizedItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = editingMealPlannerId
|
||||||
|
? await mealPlannersApi.updateMealPlanner(editingMealPlannerId, payload)
|
||||||
|
: await mealPlannersApi.createMealPlanner({
|
||||||
|
...payload,
|
||||||
|
householdId: mealPlannerForm.householdId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadPageData(result.id)
|
||||||
|
setSelectedMealPlannerId(result.id)
|
||||||
|
setEditingMealPlannerId(result.id)
|
||||||
|
setMealPlannerForm(mapMealPlannerToForm(result))
|
||||||
|
setStatusMessage(editingMealPlannerId ? 'Meal planner updated.' : 'Meal planner created.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteMealPlanner(mealPlannerId) {
|
||||||
|
const confirmed = window.confirm('Delete this meal planner?')
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mealPlannersApi.deleteMealPlanner(mealPlannerId)
|
||||||
|
await loadPageData(selectedMealPlannerId === mealPlannerId ? '' : selectedMealPlannerId)
|
||||||
|
|
||||||
|
if (editingMealPlannerId === mealPlannerId) {
|
||||||
|
resetEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Meal planner deleted.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryOptions = [...inventoryItems].sort((left, right) => (
|
||||||
|
(left.name ?? '').localeCompare(right.name ?? '')
|
||||||
|
))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Meal Planners</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before managing meal planners.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Syncing meal planners...</div>}
|
||||||
|
|
||||||
|
<div className="planning-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editingMealPlannerId ? 'Edit Meal Planner' : 'Create Meal Planner'}</h3>
|
||||||
|
{editingMealPlannerId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
|
New meal planner
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="form-note planning-form-note">
|
||||||
|
Meal planners are household-scoped. Choose a household, set the schedule, and add the inventory items required for that meal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleMealPlannerSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="meal-planner-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="meal-planner-name"
|
||||||
|
type="text"
|
||||||
|
value={mealPlannerForm.name}
|
||||||
|
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, name: event.target.value }))}
|
||||||
|
placeholder="Pasta night"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="meal-planner-household">Household</label>
|
||||||
|
<select
|
||||||
|
id="meal-planner-household"
|
||||||
|
value={mealPlannerForm.householdId}
|
||||||
|
onChange={event => setMealPlannerForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
householdId: event.target.value,
|
||||||
|
items: [],
|
||||||
|
}))}
|
||||||
|
disabled={Boolean(editingMealPlannerId)}
|
||||||
|
>
|
||||||
|
<option value="">Select a household</option>
|
||||||
|
{households.map(household => (
|
||||||
|
<option key={household.id} value={household.id}>{household.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="meal-planner-date">Planned Date</label>
|
||||||
|
<input
|
||||||
|
id="meal-planner-date"
|
||||||
|
type="date"
|
||||||
|
value={mealPlannerForm.plannedDate}
|
||||||
|
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, plannedDate: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="meal-planner-time">Planned Time</label>
|
||||||
|
<input
|
||||||
|
id="meal-planner-time"
|
||||||
|
type="time"
|
||||||
|
value={mealPlannerForm.plannedTime}
|
||||||
|
onChange={event => setMealPlannerForm(currentForm => ({ ...currentForm, plannedTime: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Meal Items</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={addItemRow}>
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mealPlannerForm.items.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No meal items yet. Add a row when you are ready.</div>
|
||||||
|
) : (
|
||||||
|
<div className="planning-item-stack">
|
||||||
|
{mealPlannerForm.items.map((item, index) => (
|
||||||
|
<div className="planning-item-row" key={`meal-planner-item-${index + 1}`}>
|
||||||
|
<div className="planning-item-row-header">
|
||||||
|
<strong>Item {index + 1}</strong>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => removeItemRow(index)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid planning-item-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`meal-planner-item-id-${index}`}>Inventory Item</label>
|
||||||
|
<select
|
||||||
|
id={`meal-planner-item-id-${index}`}
|
||||||
|
value={item.inventoryItemId}
|
||||||
|
onChange={event => updateItemRow(index, { inventoryItemId: event.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select an inventory item</option>
|
||||||
|
{inventoryOptions.map(inventoryItem => (
|
||||||
|
<option key={inventoryItem.id} value={inventoryItem.id}>
|
||||||
|
{inventoryItem.name} ({inventoryItem.location?.name || 'No location'})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`meal-planner-item-amount-${index}`}>Amount Required</label>
|
||||||
|
<input
|
||||||
|
id={`meal-planner-item-amount-${index}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={item.amountRequired}
|
||||||
|
onChange={event => updateItemRow(index, { amountRequired: event.target.value })}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`meal-planner-item-type-${index}`}>Amount Type</label>
|
||||||
|
<input
|
||||||
|
id={`meal-planner-item-type-${index}`}
|
||||||
|
type="text"
|
||||||
|
value={item.amountType}
|
||||||
|
onChange={event => updateItemRow(index, { amountType: event.target.value })}
|
||||||
|
placeholder="litres"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingMealPlannerId && (
|
||||||
|
<p className="form-note">
|
||||||
|
Household selection is locked while editing because the update endpoint only replaces the name, schedule, and planner items.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingMealPlannerId ? 'Update meal planner' : 'Create meal planner'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
|
Clear form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Meal Planners</h3>
|
||||||
|
<span className="subtle-text">{mealPlanners.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mealPlanners.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No meal planners were returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="planning-list">
|
||||||
|
{mealPlanners.map(mealPlanner => (
|
||||||
|
<article
|
||||||
|
className={`planning-card ${selectedMealPlannerId === mealPlanner.id ? 'is-selected' : ''}`}
|
||||||
|
key={mealPlanner.id}
|
||||||
|
>
|
||||||
|
<div className="planning-card-header">
|
||||||
|
<div>
|
||||||
|
<strong>{mealPlanner.name}</strong>
|
||||||
|
<div className="entity-meta">Household: {resolveHouseholdName(households, mealPlanner.householdId)}</div>
|
||||||
|
<div className="entity-meta">
|
||||||
|
Planned for {formatDate(mealPlanner.plannedDate) || 'Unknown date'} at {formatTime(mealPlanner.plannedTime) || 'Unknown time'}
|
||||||
|
</div>
|
||||||
|
<div className="entity-meta">
|
||||||
|
Created {formatDate(mealPlanner.createdAt) || 'Unknown date'} by {mealPlanner.createdByEmail || 'Unknown user'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="planning-chip">{mealPlanner.items?.length ?? 0} item(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planning-summary-list">
|
||||||
|
{(mealPlanner.items ?? []).length === 0 ? (
|
||||||
|
<div className="entity-meta">No items added yet.</div>
|
||||||
|
) : (
|
||||||
|
mealPlanner.items.map(item => (
|
||||||
|
<div className="planning-summary-row" key={item.inventoryItemId}>
|
||||||
|
<div>
|
||||||
|
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||||
|
<div className="entity-meta">
|
||||||
|
{formatAmount(item.amountRequired, item.amountType)}
|
||||||
|
{item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="planning-status-chip is-neutral">Required</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planning-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setSelectedMealPlannerId(mealPlanner.id)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleEditMealPlanner(mealPlanner.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => handleDeleteMealPlanner(mealPlanner.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MealPlannersPage
|
||||||
139
src/pages/PlanningPage.css
Normal file
139
src/pages/PlanningPage.css
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
.planning-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.05fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-form-note {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-item-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-item-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-item-row-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-item-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-checkbox-row input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-card.is-selected {
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-chip,
|
||||||
|
.planning-status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-chip {
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
color: var(--status-info-text);
|
||||||
|
border: 1px solid var(--status-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-status-chip.is-success {
|
||||||
|
background: var(--status-success-bg);
|
||||||
|
color: var(--status-success-text);
|
||||||
|
border: 1px solid var(--status-success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-status-chip.is-neutral {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.planning-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.planning-card-header,
|
||||||
|
.planning-summary-row,
|
||||||
|
.planning-item-row-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/pages/ProfilePage/ProfilePage.css
Normal file
56
src/pages/ProfilePage/ProfilePage.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.8fr) minmax(0, 1.2fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-summary-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-role-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
color: var(--status-info-text);
|
||||||
|
border: 1px solid var(--status-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-role-badge--empty {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-color: var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form-note {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/pages/ProfilePage/ProfilePage.jsx
Normal file
242
src/pages/ProfilePage/ProfilePage.jsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { profileApi } from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import './ProfilePage.css'
|
||||||
|
|
||||||
|
const EMPTY_PROFILE_FORM = {
|
||||||
|
email: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProfileForm(user) {
|
||||||
|
return {
|
||||||
|
email: user?.email ?? '',
|
||||||
|
firstName: user?.firstName ?? '',
|
||||||
|
lastName: user?.lastName ?? '',
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const { isAuthenticated, refreshProfile, setCurrentUserProfile, user } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [profileForm, setProfileForm] = useState(() => createProfileForm(user))
|
||||||
|
|
||||||
|
const roles = Array.isArray(user?.roles) ? user.roles : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setProfileForm(EMPTY_PROFILE_FORM)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
email: user?.email ?? '',
|
||||||
|
firstName: user?.firstName ?? '',
|
||||||
|
lastName: user?.lastName ?? '',
|
||||||
|
}))
|
||||||
|
}, [isAuthenticated, user?.email, user?.firstName, user?.lastName])
|
||||||
|
|
||||||
|
async function handleRefreshProfile() {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestProfile = await refreshProfile()
|
||||||
|
|
||||||
|
if (latestProfile) {
|
||||||
|
setProfileForm(createProfileForm(latestProfile))
|
||||||
|
setStatusMessage('Profile refreshed.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProfileSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const email = profileForm.email.trim()
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
setErrorMessage('Email is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileForm.newPassword && !profileForm.currentPassword) {
|
||||||
|
setErrorMessage('Current password is required to change your password.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedProfile = await profileApi.updateProfile({
|
||||||
|
email,
|
||||||
|
firstName: profileForm.firstName,
|
||||||
|
lastName: profileForm.lastName,
|
||||||
|
currentPassword: profileForm.currentPassword || undefined,
|
||||||
|
newPassword: profileForm.newPassword || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCurrentUserProfile(updatedProfile)
|
||||||
|
setProfileForm(createProfileForm(updatedProfile))
|
||||||
|
setStatusMessage('Profile updated.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before updating your profile.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Refreshing your profile...</div>}
|
||||||
|
{saving && <div className="status-banner status-banner--info">Saving profile changes...</div>}
|
||||||
|
|
||||||
|
<div className="profile-grid">
|
||||||
|
<section className="panel profile-summary-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Account Summary</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleRefreshProfile}>
|
||||||
|
Refresh profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-meta">
|
||||||
|
<strong>{[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.email || 'Signed in user'}</strong>
|
||||||
|
<div className="entity-meta">Email: {user?.email || 'Not available'}</div>
|
||||||
|
<div className="entity-meta">User ID: {user?.id || 'Not available'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-role-section">
|
||||||
|
<span className="subtle-text">Assigned roles</span>
|
||||||
|
<div className="profile-role-list">
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<span className="profile-role-badge profile-role-badge--empty">No roles assigned</span>
|
||||||
|
) : (
|
||||||
|
roles.map(role => (
|
||||||
|
<span className="profile-role-badge" key={role}>{role}</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Update Profile</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="form-note profile-form-note">
|
||||||
|
Leave the password fields blank unless you want to change your password. First and last name fields can be cleared.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleProfileSubmit}>
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="profile-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="profile-email"
|
||||||
|
type="email"
|
||||||
|
value={profileForm.email}
|
||||||
|
onChange={event => setProfileForm(currentForm => ({ ...currentForm, email: event.target.value }))}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="profile-first-name">First Name</label>
|
||||||
|
<input
|
||||||
|
id="profile-first-name"
|
||||||
|
type="text"
|
||||||
|
value={profileForm.firstName}
|
||||||
|
onChange={event => setProfileForm(currentForm => ({ ...currentForm, firstName: event.target.value }))}
|
||||||
|
placeholder="Alex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="profile-last-name">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="profile-last-name"
|
||||||
|
type="text"
|
||||||
|
value={profileForm.lastName}
|
||||||
|
onChange={event => setProfileForm(currentForm => ({ ...currentForm, lastName: event.target.value }))}
|
||||||
|
placeholder="Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="profile-current-password">Current Password</label>
|
||||||
|
<input
|
||||||
|
id="profile-current-password"
|
||||||
|
type="password"
|
||||||
|
value={profileForm.currentPassword}
|
||||||
|
onChange={event => setProfileForm(currentForm => ({ ...currentForm, currentPassword: event.target.value }))}
|
||||||
|
placeholder="Required to change password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="profile-new-password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="profile-new-password"
|
||||||
|
type="password"
|
||||||
|
value={profileForm.newPassword}
|
||||||
|
onChange={event => setProfileForm(currentForm => ({ ...currentForm, newPassword: event.target.value }))}
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
Save profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setProfileForm(createProfileForm(user))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage
|
||||||
199
src/pages/SearchPage/SearchPage.css
Normal file
199
src/pages/SearchPage/SearchPage.css
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
.search-container {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--color-input-bg);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-primary-bg);
|
||||||
|
box-shadow: 0 0 5px var(--button-primary-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-range,
|
||||||
|
.date-range {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-range .form-group,
|
||||||
|
.date-range .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-results {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-results-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-result-card {
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-expiry-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-fresh {
|
||||||
|
background-color: var(--status-success-bg);
|
||||||
|
color: var(--status-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-soon {
|
||||||
|
background-color: var(--status-warning-bg);
|
||||||
|
color: var(--status-warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry-expired {
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
color: var(--status-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 30px 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: var(--color-input-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--button-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-surface-muted);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button:hover:not(:disabled) {
|
||||||
|
background-color: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
border-color: var(--button-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.quantity-range,
|
||||||
|
.date-range,
|
||||||
|
.search-buttons,
|
||||||
|
.pagination-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-size,
|
||||||
|
.pagination-nav {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/pages/SearchPage/SearchPage.jsx
Normal file
371
src/pages/SearchPage/SearchPage.jsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||||
|
import {
|
||||||
|
inventoryApi,
|
||||||
|
locationsApi,
|
||||||
|
searchApi,
|
||||||
|
} from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import {
|
||||||
|
filterInventoryItems,
|
||||||
|
formatAmount,
|
||||||
|
formatDate,
|
||||||
|
getExpiryStatus,
|
||||||
|
} from '../../utils/searchUtils.js'
|
||||||
|
import { getPaginatedResults } from '../../utils/paginationUtils.js'
|
||||||
|
import './SearchPage.css'
|
||||||
|
|
||||||
|
function ExpiryBadge({ expiryDate }) {
|
||||||
|
const status = getExpiryStatus(expiryDate)
|
||||||
|
const badgeClass = status.status === 'Expired'
|
||||||
|
? 'expiry-expired'
|
||||||
|
: (status.status === 'Soon' || status.status === 'Today')
|
||||||
|
? 'expiry-soon'
|
||||||
|
: 'expiry-fresh'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`item-expiry-badge ${badgeClass}`}>
|
||||||
|
{formatDate(expiryDate) || 'No expiry date'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchPage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [searchName, setSearchName] = useState('')
|
||||||
|
const [locationId, setLocationId] = useState('')
|
||||||
|
const [minAmount, setMinAmount] = useState('0')
|
||||||
|
const [maxAmount, setMaxAmount] = useState('999')
|
||||||
|
const [minExpiryDate, setMinExpiryDate] = useState('')
|
||||||
|
const [maxExpiryDate, setMaxExpiryDate] = useState('')
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(15)
|
||||||
|
const [locations, setLocations] = useState([])
|
||||||
|
const [matchingLocations, setMatchingLocations] = useState([])
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setLocations([])
|
||||||
|
setMatchingLocations([])
|
||||||
|
setResults([])
|
||||||
|
setErrorMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocations(nextLocations)
|
||||||
|
setMatchingLocations([])
|
||||||
|
setResults(nextItems)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialData()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
async function performSearch() {
|
||||||
|
const parsedMinAmount = minAmount === '' ? 0 : Number(minAmount)
|
||||||
|
const parsedMaxAmount = maxAmount === '' ? Infinity : Number(maxAmount)
|
||||||
|
const safeMinAmount = Number.isFinite(parsedMinAmount) ? parsedMinAmount : 0
|
||||||
|
const safeMaxAmount = maxAmount === '' || !Number.isFinite(parsedMaxAmount) ? Infinity : parsedMaxAmount
|
||||||
|
|
||||||
|
if (minExpiryDate && maxExpiryDate && minExpiryDate > maxExpiryDate) {
|
||||||
|
alert('Start date cannot be after end date')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeMinAmount > safeMaxAmount) {
|
||||||
|
alert('Minimum amount cannot be greater than maximum amount')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const trimmedQuery = searchName.trim()
|
||||||
|
const [nextItems, nextMatchingLocations] = trimmedQuery
|
||||||
|
? await Promise.all([
|
||||||
|
searchApi.searchItems(trimmedQuery),
|
||||||
|
searchApi.searchLocations(trimmedQuery),
|
||||||
|
])
|
||||||
|
: await Promise.all([
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredItems = filterInventoryItems(nextItems, {
|
||||||
|
locationId,
|
||||||
|
minAmount: safeMinAmount,
|
||||||
|
maxAmount: safeMaxAmount,
|
||||||
|
minExpiryDate,
|
||||||
|
maxExpiryDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCurrentPage(1)
|
||||||
|
setResults(filteredItems)
|
||||||
|
setMatchingLocations(nextMatchingLocations)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetForm() {
|
||||||
|
setSearchName('')
|
||||||
|
setLocationId('')
|
||||||
|
setMinAmount('0')
|
||||||
|
setMaxAmount('999')
|
||||||
|
setMinExpiryDate('')
|
||||||
|
setMaxExpiryDate('')
|
||||||
|
setCurrentPage(1)
|
||||||
|
setPageSize(15)
|
||||||
|
setMatchingLocations([])
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
|
locationsApi.getLocations(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
])
|
||||||
|
|
||||||
|
setLocations(nextLocations)
|
||||||
|
setResults(nextItems)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationData = getPaginatedResults(results, currentPage, pageSize)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Search Inventory</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before using the protected item and location search endpoints.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Searching the API...</div>}
|
||||||
|
|
||||||
|
<div className="search-container panel">
|
||||||
|
<div className="search-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="search-name">Item / Location / Barcode Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-name"
|
||||||
|
placeholder="milk, fridge, 01234567890..."
|
||||||
|
autoComplete="off"
|
||||||
|
value={searchName}
|
||||||
|
onChange={event => setSearchName(event.target.value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
performSearch()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="search-location">Storage location</label>
|
||||||
|
<select id="search-location" value={locationId} onChange={event => setLocationId(event.target.value)}>
|
||||||
|
<option value="">All locations</option>
|
||||||
|
{locations.map(location => (
|
||||||
|
<option key={location.id} value={location.id}>{location.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Amount range</label>
|
||||||
|
<div className="quantity-range">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="min-amount" style={{ fontSize: '12px' }}>Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="min-amount"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={event => setMinAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="max-amount" style={{ fontSize: '12px' }}>Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="max-amount"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={event => setMaxAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Expiry date range</label>
|
||||||
|
<div className="date-range">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="min-expiry-date"
|
||||||
|
value={minExpiryDate}
|
||||||
|
onChange={event => setMinExpiryDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="max-expiry-date"
|
||||||
|
value={maxExpiryDate}
|
||||||
|
onChange={event => setMaxExpiryDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-buttons">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={performSearch}>Search</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetForm}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchName.trim() && (
|
||||||
|
<div className="location-results panel">
|
||||||
|
<h3>Matching Locations</h3>
|
||||||
|
{matchingLocations.length === 0 ? (
|
||||||
|
<div className="empty-state">No locations matched that query.</div>
|
||||||
|
) : (
|
||||||
|
<div className="location-results-grid">
|
||||||
|
{matchingLocations.map(location => (
|
||||||
|
<div className="location-result-card" key={location.id}>
|
||||||
|
<strong>{location.name}</strong>
|
||||||
|
<div>{location.description || 'No description provided.'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="results-container panel">
|
||||||
|
<div className="results-info">
|
||||||
|
{results.length === 0
|
||||||
|
? 'No results'
|
||||||
|
: `Found ${results.length} item${results.length !== 1 ? 's' : ''} (showing ${paginationData.startIndex}-${paginationData.endIndex})`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<div className="pagination-size">
|
||||||
|
<label htmlFor="page-size-select">Items per page:</label>
|
||||||
|
<select
|
||||||
|
id="page-size-select"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={event => {
|
||||||
|
setPageSize(parseInt(event.target.value, 10))
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="15">15</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
<option value="60">60</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="pagination-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={paginationData.currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<div className="pagination-info">
|
||||||
|
Page {paginationData.currentPage} of {paginationData.totalPages}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={paginationData.currentPage === paginationData.totalPages}
|
||||||
|
onClick={() => setCurrentPage(page => page + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className="no-results">No items found matching your search criteria.</div>
|
||||||
|
) : (
|
||||||
|
<div className="item-component-grid">
|
||||||
|
{paginationData.items.map(item => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
imgAlt={item.name}
|
||||||
|
text1={item.name}
|
||||||
|
text2={`${item.location?.name || 'No location'} | ${formatAmount(item.amount, item.amountType)}`}
|
||||||
|
text3={`Barcode: ${item.barcode || 'Not set'} | Expires: ${formatDate(item.expiryDate) || 'Not set'}`}
|
||||||
|
editable={false}
|
||||||
|
>
|
||||||
|
<ExpiryBadge expiryDate={item.expiryDate} />
|
||||||
|
</ItemCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchPage
|
||||||
560
src/pages/ShoppingListsPage/ShoppingListsPage.jsx
Normal file
560
src/pages/ShoppingListsPage/ShoppingListsPage.jsx
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
householdsApi,
|
||||||
|
inventoryApi,
|
||||||
|
shoppingListsApi,
|
||||||
|
} from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
||||||
|
import '../PlanningPage.css'
|
||||||
|
|
||||||
|
function createShoppingListItemForm() {
|
||||||
|
return {
|
||||||
|
inventoryItemId: '',
|
||||||
|
amountRequired: '',
|
||||||
|
amountType: '',
|
||||||
|
isPurchased: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShoppingListForm(householdId = '') {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
householdId,
|
||||||
|
items: [createShoppingListItemForm()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapShoppingListToForm(shoppingList) {
|
||||||
|
return {
|
||||||
|
name: shoppingList?.name ?? '',
|
||||||
|
householdId: shoppingList?.householdId ?? '',
|
||||||
|
items: Array.isArray(shoppingList?.items) && shoppingList.items.length > 0
|
||||||
|
? shoppingList.items.map(item => ({
|
||||||
|
inventoryItemId: item.inventoryItemId ?? '',
|
||||||
|
amountRequired: item.amountRequired == null ? '' : String(item.amountRequired),
|
||||||
|
amountType: item.amountType ?? '',
|
||||||
|
isPurchased: Boolean(item.isPurchased),
|
||||||
|
}))
|
||||||
|
: [createShoppingListItemForm()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHouseholdName(households, householdId) {
|
||||||
|
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShoppingListItems(items) {
|
||||||
|
const normalizedItems = items
|
||||||
|
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
|
||||||
|
.map((item, index) => {
|
||||||
|
const amountRequired = Number(item.amountRequired)
|
||||||
|
const amountType = item.amountType.trim()
|
||||||
|
|
||||||
|
if (!item.inventoryItemId) {
|
||||||
|
throw new Error(`Choose an inventory item for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(amountRequired) || amountRequired <= 0) {
|
||||||
|
throw new Error(`Amount required must be greater than zero for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amountType) {
|
||||||
|
throw new Error(`Amount type is required for row ${index + 1}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inventoryItemId: item.inventoryItemId,
|
||||||
|
amountRequired,
|
||||||
|
amountType,
|
||||||
|
isPurchased: Boolean(item.isPurchased),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const distinctIds = new Set(normalizedItems.map(item => item.inventoryItemId))
|
||||||
|
|
||||||
|
if (distinctIds.size !== normalizedItems.length) {
|
||||||
|
throw new Error('Each inventory item can only appear once in a shopping list.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShoppingListsPage() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [households, setHouseholds] = useState([])
|
||||||
|
const [inventoryItems, setInventoryItems] = useState([])
|
||||||
|
const [shoppingLists, setShoppingLists] = useState([])
|
||||||
|
const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
|
||||||
|
const [editingShoppingListId, setEditingShoppingListId] = useState('')
|
||||||
|
const [shoppingListForm, setShoppingListForm] = useState(createShoppingListForm())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialData() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setHouseholds([])
|
||||||
|
setInventoryItems([])
|
||||||
|
setShoppingLists([])
|
||||||
|
setSelectedShoppingListId('')
|
||||||
|
setEditingShoppingListId('')
|
||||||
|
setShoppingListForm(createShoppingListForm())
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
|
||||||
|
householdsApi.getHouseholds(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
shoppingListsApi.getShoppingLists(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
|
||||||
|
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
|
||||||
|
const nextShoppingLists = Array.isArray(nextShoppingListsResponse) ? nextShoppingListsResponse : []
|
||||||
|
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setInventoryItems(nextInventoryItems)
|
||||||
|
setShoppingLists(nextShoppingLists)
|
||||||
|
setSelectedShoppingListId(nextShoppingLists[0]?.id ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialData()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingShoppingListId || shoppingListForm.householdId || households.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setShoppingListForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
householdId: households[0].id,
|
||||||
|
}))
|
||||||
|
}, [editingShoppingListId, households, shoppingListForm.householdId])
|
||||||
|
|
||||||
|
async function loadPageData(preferredSelectionId = '') {
|
||||||
|
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
|
||||||
|
householdsApi.getHouseholds(),
|
||||||
|
inventoryApi.getInventoryItems(),
|
||||||
|
shoppingListsApi.getShoppingLists(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextHouseholds = Array.isArray(nextHouseholdsResponse) ? nextHouseholdsResponse : []
|
||||||
|
const nextInventoryItems = Array.isArray(nextInventoryResponse) ? nextInventoryResponse : []
|
||||||
|
const nextShoppingLists = Array.isArray(nextShoppingListsResponse) ? nextShoppingListsResponse : []
|
||||||
|
|
||||||
|
setHouseholds(nextHouseholds)
|
||||||
|
setInventoryItems(nextInventoryItems)
|
||||||
|
setShoppingLists(nextShoppingLists)
|
||||||
|
setSelectedShoppingListId(currentSelectionId => {
|
||||||
|
const targetSelectionId = preferredSelectionId || currentSelectionId
|
||||||
|
|
||||||
|
if (nextShoppingLists.some(shoppingList => shoppingList.id === targetSelectionId)) {
|
||||||
|
return targetSelectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextShoppingLists[0]?.id ?? ''
|
||||||
|
})
|
||||||
|
setEditingShoppingListId(currentEditingId => (
|
||||||
|
nextShoppingLists.some(shoppingList => shoppingList.id === currentEditingId)
|
||||||
|
? currentEditingId
|
||||||
|
: ''
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
setEditingShoppingListId('')
|
||||||
|
setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemRow(index, updates) {
|
||||||
|
setShoppingListForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: currentForm.items.map((item, itemIndex) => (
|
||||||
|
itemIndex === index
|
||||||
|
? { ...item, ...updates }
|
||||||
|
: item
|
||||||
|
)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItemRow() {
|
||||||
|
setShoppingListForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: [...currentForm.items, createShoppingListItemForm()],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemRow(index) {
|
||||||
|
setShoppingListForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditShoppingList(shoppingListId) {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shoppingList = await shoppingListsApi.getShoppingList(shoppingListId)
|
||||||
|
setSelectedShoppingListId(shoppingList.id)
|
||||||
|
setEditingShoppingListId(shoppingList.id)
|
||||||
|
setShoppingListForm(mapShoppingListToForm(shoppingList))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShoppingListSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const name = shoppingListForm.name.trim()
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setErrorMessage('Shopping list name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingShoppingListId && !shoppingListForm.householdId) {
|
||||||
|
setErrorMessage('Choose a household before creating a shopping list.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedItems
|
||||||
|
|
||||||
|
try {
|
||||||
|
normalizedItems = normalizeShoppingListItems(shoppingListForm.items)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
items: normalizedItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = editingShoppingListId
|
||||||
|
? await shoppingListsApi.updateShoppingList(editingShoppingListId, payload)
|
||||||
|
: await shoppingListsApi.createShoppingList({
|
||||||
|
...payload,
|
||||||
|
householdId: shoppingListForm.householdId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadPageData(result.id)
|
||||||
|
setSelectedShoppingListId(result.id)
|
||||||
|
setEditingShoppingListId(result.id)
|
||||||
|
setShoppingListForm(mapShoppingListToForm(result))
|
||||||
|
setStatusMessage(editingShoppingListId ? 'Shopping list updated.' : 'Shopping list created.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteShoppingList(shoppingListId) {
|
||||||
|
const confirmed = window.confirm('Delete this shopping list?')
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await shoppingListsApi.deleteShoppingList(shoppingListId)
|
||||||
|
await loadPageData(selectedShoppingListId === shoppingListId ? '' : selectedShoppingListId)
|
||||||
|
|
||||||
|
if (editingShoppingListId === shoppingListId) {
|
||||||
|
resetEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Shopping list deleted.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryOptions = [...inventoryItems].sort((left, right) => (
|
||||||
|
(left.name ?? '').localeCompare(right.name ?? '')
|
||||||
|
))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Shopping Lists</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before managing shopping lists.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Syncing shopping lists...</div>}
|
||||||
|
|
||||||
|
<div className="planning-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editingShoppingListId ? 'Edit Shopping List' : 'Create Shopping List'}</h3>
|
||||||
|
{editingShoppingListId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
|
New shopping list
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="form-note planning-form-note">
|
||||||
|
Shopping lists are household-scoped. Pick the household first, then add inventory items that belong to members of that household.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="editor-form" onSubmit={handleShoppingListSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="shopping-list-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="shopping-list-name"
|
||||||
|
type="text"
|
||||||
|
value={shoppingListForm.name}
|
||||||
|
onChange={event => setShoppingListForm(currentForm => ({ ...currentForm, name: event.target.value }))}
|
||||||
|
placeholder="Weekend shop"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="shopping-list-household">Household</label>
|
||||||
|
<select
|
||||||
|
id="shopping-list-household"
|
||||||
|
value={shoppingListForm.householdId}
|
||||||
|
onChange={event => setShoppingListForm(currentForm => ({
|
||||||
|
...currentForm,
|
||||||
|
householdId: event.target.value,
|
||||||
|
items: [],
|
||||||
|
}))}
|
||||||
|
disabled={Boolean(editingShoppingListId)}
|
||||||
|
>
|
||||||
|
<option value="">Select a household</option>
|
||||||
|
{households.map(household => (
|
||||||
|
<option key={household.id} value={household.id}>{household.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>List Items</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={addItemRow}>
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shoppingListForm.items.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No list items yet. Add a row when you are ready.</div>
|
||||||
|
) : (
|
||||||
|
<div className="planning-item-stack">
|
||||||
|
{shoppingListForm.items.map((item, index) => (
|
||||||
|
<div className="planning-item-row" key={`shopping-list-item-${index + 1}`}>
|
||||||
|
<div className="planning-item-row-header">
|
||||||
|
<strong>Item {index + 1}</strong>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => removeItemRow(index)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid planning-item-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`shopping-list-item-id-${index}`}>Inventory Item</label>
|
||||||
|
<select
|
||||||
|
id={`shopping-list-item-id-${index}`}
|
||||||
|
value={item.inventoryItemId}
|
||||||
|
onChange={event => updateItemRow(index, { inventoryItemId: event.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select an inventory item</option>
|
||||||
|
{inventoryOptions.map(inventoryItem => (
|
||||||
|
<option key={inventoryItem.id} value={inventoryItem.id}>
|
||||||
|
{inventoryItem.name} ({inventoryItem.location?.name || 'No location'})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`shopping-list-item-amount-${index}`}>Amount Required</label>
|
||||||
|
<input
|
||||||
|
id={`shopping-list-item-amount-${index}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={item.amountRequired}
|
||||||
|
onChange={event => updateItemRow(index, { amountRequired: event.target.value })}
|
||||||
|
placeholder="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor={`shopping-list-item-type-${index}`}>Amount Type</label>
|
||||||
|
<input
|
||||||
|
id={`shopping-list-item-type-${index}`}
|
||||||
|
type="text"
|
||||||
|
value={item.amountType}
|
||||||
|
onChange={event => updateItemRow(index, { amountType: event.target.value })}
|
||||||
|
placeholder="cartons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planning-checkbox-row">
|
||||||
|
<input
|
||||||
|
id={`shopping-list-item-purchased-${index}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.isPurchased}
|
||||||
|
onChange={event => updateItemRow(index, { isPurchased: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`shopping-list-item-purchased-${index}`}>Purchased</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingShoppingListId && (
|
||||||
|
<p className="form-note">
|
||||||
|
Household selection is locked while editing because the update endpoint only replaces the name and list items.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{editingShoppingListId ? 'Update shopping list' : 'Create shopping list'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
|
Clear form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>Shopping Lists</h3>
|
||||||
|
<span className="subtle-text">{shoppingLists.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shoppingLists.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No shopping lists were returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="planning-list">
|
||||||
|
{shoppingLists.map(shoppingList => (
|
||||||
|
<article
|
||||||
|
className={`planning-card ${selectedShoppingListId === shoppingList.id ? 'is-selected' : ''}`}
|
||||||
|
key={shoppingList.id}
|
||||||
|
>
|
||||||
|
<div className="planning-card-header">
|
||||||
|
<div>
|
||||||
|
<strong>{shoppingList.name}</strong>
|
||||||
|
<div className="entity-meta">Household: {resolveHouseholdName(households, shoppingList.householdId)}</div>
|
||||||
|
<div className="entity-meta">
|
||||||
|
Created {formatDate(shoppingList.createdAt) || 'Unknown date'} by {shoppingList.createdByEmail || 'Unknown user'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="planning-chip">{shoppingList.items?.length ?? 0} item(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planning-summary-list">
|
||||||
|
{(shoppingList.items ?? []).length === 0 ? (
|
||||||
|
<div className="entity-meta">No items added yet.</div>
|
||||||
|
) : (
|
||||||
|
shoppingList.items.map(item => (
|
||||||
|
<div className="planning-summary-row" key={item.inventoryItemId}>
|
||||||
|
<div>
|
||||||
|
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||||
|
<div className="entity-meta">
|
||||||
|
{formatAmount(item.amountRequired, item.amountType)}
|
||||||
|
{item.inventoryItemBarcode ? ` | ${item.inventoryItemBarcode}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`planning-status-chip ${item.isPurchased ? 'is-success' : 'is-neutral'}`}>
|
||||||
|
{item.isPurchased ? 'Purchased' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planning-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setSelectedShoppingListId(shoppingList.id)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleEditShoppingList(shoppingList.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => handleDeleteShoppingList(shoppingList.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShoppingListsPage
|
||||||
77
src/pages/UsersPage/UsersPage.css
Normal file
77
src/pages/UsersPage/UsersPage.css
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
.users-management-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-note {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-page-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card.is-selected {
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-role-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
color: var(--status-info-text);
|
||||||
|
border: 1px solid var(--status-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-role-badge--empty {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-color: var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-editor-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.users-management-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/pages/UsersPage/UsersPage.jsx
Normal file
371
src/pages/UsersPage/UsersPage.jsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { usersApi } from '../../api/client.js'
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx'
|
||||||
|
import './UsersPage.css'
|
||||||
|
|
||||||
|
const EMPTY_USER_FORM = {
|
||||||
|
email: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
password: '',
|
||||||
|
rolesInput: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserName(user) {
|
||||||
|
const fullName = [user.firstName, user.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return fullName || user.email || 'Unnamed user'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserToForm(user) {
|
||||||
|
return {
|
||||||
|
email: user?.email ?? '',
|
||||||
|
firstName: user?.firstName ?? '',
|
||||||
|
lastName: user?.lastName ?? '',
|
||||||
|
password: '',
|
||||||
|
rolesInput: Array.isArray(user?.roles) ? user.roles.join(', ') : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoles(value) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map(role => role.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState('')
|
||||||
|
const [editingUserId, setEditingUserId] = useState('')
|
||||||
|
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
||||||
|
|
||||||
|
async function loadUsers(preferredSelectionId = '') {
|
||||||
|
const response = await usersApi.getUsers()
|
||||||
|
const nextUsers = Array.isArray(response) ? response : []
|
||||||
|
|
||||||
|
setUsers(nextUsers)
|
||||||
|
setSelectedUserId(currentSelectionId => {
|
||||||
|
const targetSelectionId = preferredSelectionId || currentSelectionId
|
||||||
|
|
||||||
|
if (nextUsers.some(existingUser => existingUser.id === targetSelectionId)) {
|
||||||
|
return targetSelectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextUsers[0]?.id ?? ''
|
||||||
|
})
|
||||||
|
setEditingUserId(currentEditingId => (
|
||||||
|
nextUsers.some(existingUser => existingUser.id === currentEditingId)
|
||||||
|
? currentEditingId
|
||||||
|
: ''
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadInitialUsers() {
|
||||||
|
if (!isAuthenticated || !isSiteAdmin) {
|
||||||
|
setUsers([])
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
setSelectedUserId('')
|
||||||
|
setEditingUserId('')
|
||||||
|
setUserForm(EMPTY_USER_FORM)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await usersApi.getUsers()
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const nextUsers = Array.isArray(response) ? response : []
|
||||||
|
setUsers(nextUsers)
|
||||||
|
setSelectedUserId(nextUsers[0]?.id ?? '')
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitialUsers()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isSiteAdmin])
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
setEditingUserId('')
|
||||||
|
setUserForm(EMPTY_USER_FORM)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshUsers() {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadUsers(selectedUserId)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditUser(userId) {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextUser = await usersApi.getUser(userId)
|
||||||
|
setSelectedUserId(nextUser.id)
|
||||||
|
setEditingUserId(nextUser.id)
|
||||||
|
setUserForm(mapUserToForm(nextUser))
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUserSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!editingUserId) {
|
||||||
|
setErrorMessage('Select a user before editing details.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = userForm.email.trim()
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
setErrorMessage('User email is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await usersApi.updateUser(editingUserId, {
|
||||||
|
email,
|
||||||
|
firstName: userForm.firstName,
|
||||||
|
lastName: userForm.lastName,
|
||||||
|
password: userForm.password || undefined,
|
||||||
|
roles: parseRoles(userForm.rolesInput),
|
||||||
|
})
|
||||||
|
|
||||||
|
await loadUsers(updatedUser.id)
|
||||||
|
setSelectedUserId(updatedUser.id)
|
||||||
|
setEditingUserId(updatedUser.id)
|
||||||
|
setUserForm(mapUserToForm(updatedUser))
|
||||||
|
|
||||||
|
if (user?.id === updatedUser.id) {
|
||||||
|
await refreshProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('User updated.')
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
Sign in on the dashboard before using the protected users endpoints.
|
||||||
|
</div>
|
||||||
|
) : !isSiteAdmin ? (
|
||||||
|
<div className="auth-required">
|
||||||
|
The users page is reserved for accounts with the site admin role.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
||||||
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
||||||
|
{loading && <div className="status-banner status-banner--info">Loading users...</div>}
|
||||||
|
{saving && <div className="status-banner status-banner--info">Saving user changes...</div>}
|
||||||
|
|
||||||
|
<div className="users-management-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>{editingUserId ? 'Edit User' : 'User Editor'}</h3>
|
||||||
|
{editingUserId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor}>
|
||||||
|
Clear editor
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="form-note users-page-note">
|
||||||
|
Roles are replaced with the comma-separated list you submit. Leave the password blank if you do not want to reset it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!editingUserId ? (
|
||||||
|
<div className="empty-state compact-empty-state">Choose a user from the directory to load the edit form.</div>
|
||||||
|
) : (
|
||||||
|
<form className="editor-form" onSubmit={handleUserSubmit}>
|
||||||
|
<div className="users-editor-meta">
|
||||||
|
<strong>{formatUserName(userForm)}</strong>
|
||||||
|
<div className="entity-meta">Editing user ID: {editingUserId}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="users-email">Email</label>
|
||||||
|
<input
|
||||||
|
id="users-email"
|
||||||
|
type="email"
|
||||||
|
value={userForm.email}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, email: event.target.value }))}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="users-first-name">First Name</label>
|
||||||
|
<input
|
||||||
|
id="users-first-name"
|
||||||
|
type="text"
|
||||||
|
value={userForm.firstName}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, firstName: event.target.value }))}
|
||||||
|
placeholder="Alex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="users-last-name">Last Name</label>
|
||||||
|
<input
|
||||||
|
id="users-last-name"
|
||||||
|
type="text"
|
||||||
|
value={userForm.lastName}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, lastName: event.target.value }))}
|
||||||
|
placeholder="Smith"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="users-password">Reset Password</label>
|
||||||
|
<input
|
||||||
|
id="users-password"
|
||||||
|
type="password"
|
||||||
|
value={userForm.password}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, password: event.target.value }))}
|
||||||
|
placeholder="Leave blank to keep the current password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-group">
|
||||||
|
<label htmlFor="users-roles">Roles</label>
|
||||||
|
<input
|
||||||
|
id="users-roles"
|
||||||
|
type="text"
|
||||||
|
value={userForm.rolesInput}
|
||||||
|
onChange={event => setUserForm(current => ({ ...current, rolesInput: event.target.value }))}
|
||||||
|
placeholder="Site Admin, Admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
Save user
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetEditor} disabled={saving}>
|
||||||
|
Reset editor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel users-page-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h3>User Directory</h3>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleRefreshUsers}>
|
||||||
|
Refresh users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="empty-state compact-empty-state">No users were returned.</div>
|
||||||
|
) : (
|
||||||
|
<div className="users-grid">
|
||||||
|
{users.map(directoryUser => {
|
||||||
|
const roles = Array.isArray(directoryUser.roles) ? directoryUser.roles : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`user-card ${selectedUserId === directoryUser.id ? 'is-selected' : ''}`}
|
||||||
|
key={directoryUser.id ?? directoryUser.email}
|
||||||
|
>
|
||||||
|
<strong>{formatUserName(directoryUser)}</strong>
|
||||||
|
<div className="entity-meta">{directoryUser.email || 'No email provided.'}</div>
|
||||||
|
<div className="entity-meta">ID: {directoryUser.id || 'Not set'}</div>
|
||||||
|
<div className="users-role-list">
|
||||||
|
{roles.length === 0 ? (
|
||||||
|
<span className="users-role-badge users-role-badge--empty">No roles</span>
|
||||||
|
) : (
|
||||||
|
roles.map(role => (
|
||||||
|
<span className="users-role-badge" key={role}>{role}</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="users-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setSelectedUserId(directoryUser.id)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleEditUser(directoryUser.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPage
|
||||||
63
src/utils/inventoryItemUtils.js
Normal file
63
src/utils/inventoryItemUtils.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { toDateInputValue } from './searchUtils.js'
|
||||||
|
|
||||||
|
export const EMPTY_ITEM_FORM = {
|
||||||
|
name: '',
|
||||||
|
barcode: '',
|
||||||
|
expiryDate: '',
|
||||||
|
useByDate: '',
|
||||||
|
amount: '',
|
||||||
|
amountType: '',
|
||||||
|
locationId: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBarcode(value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createItemForm(barcode = '') {
|
||||||
|
return {
|
||||||
|
...EMPTY_ITEM_FORM,
|
||||||
|
barcode: normalizeBarcode(barcode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInventoryPayload(form, includeBlankText = false) {
|
||||||
|
const payload = {}
|
||||||
|
const name = form.name.trim()
|
||||||
|
|
||||||
|
const barcode = normalizeBarcode(form.barcode)
|
||||||
|
const amountType = form.amountType.trim()
|
||||||
|
|
||||||
|
if (name || includeBlankText) payload.name = name
|
||||||
|
if (barcode || includeBlankText) payload.barcode = barcode
|
||||||
|
if (amountType || includeBlankText) payload.amountType = amountType
|
||||||
|
|
||||||
|
if (form.expiryDate) payload.expiryDate = form.expiryDate
|
||||||
|
if (form.useByDate) payload.useByDate = form.useByDate
|
||||||
|
if (form.amount !== '') payload.amount = Number(form.amount)
|
||||||
|
if (form.locationId) payload.locationId = form.locationId
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapItemToForm(item) {
|
||||||
|
return {
|
||||||
|
name: item.name ?? '',
|
||||||
|
barcode: item.barcode ?? '',
|
||||||
|
expiryDate: toDateInputValue(item.expiryDate),
|
||||||
|
useByDate: toDateInputValue(item.useByDate),
|
||||||
|
amount: item.amount == null ? '' : String(item.amount),
|
||||||
|
amountType: item.amountType ?? '',
|
||||||
|
locationId: item.locationId ?? item.location?.id ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findItemsByBarcode(items, barcode) {
|
||||||
|
const normalizedBarcode = normalizeBarcode(barcode)
|
||||||
|
|
||||||
|
if (!normalizedBarcode) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter(item => normalizeBarcode(item.barcode) === normalizedBarcode)
|
||||||
|
}
|
||||||
22
src/utils/paginationUtils.js
Normal file
22
src/utils/paginationUtils.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export function getPaginatedResults(filteredItems, currentPage, pageSize) {
|
||||||
|
const totalItems = filteredItems.length
|
||||||
|
const totalPages = Math.ceil(totalItems / pageSize) || 1
|
||||||
|
|
||||||
|
let page = currentPage
|
||||||
|
if (page < 1) page = 1
|
||||||
|
if (page > totalPages) page = totalPages
|
||||||
|
|
||||||
|
const startIndex = (page - 1) * pageSize
|
||||||
|
const endIndex = startIndex + pageSize
|
||||||
|
const items = filteredItems.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
currentPage: page,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
totalItems,
|
||||||
|
startIndex: totalItems > 0 ? startIndex + 1 : 0,
|
||||||
|
endIndex: Math.min(endIndex, totalItems),
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/utils/searchUtils.js
Normal file
102
src/utils/searchUtils.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
export function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
|
||||||
|
const normalizedDate = toDateInputValue(dateStr)
|
||||||
|
const date = new Date(`${normalizedDate}T00:00:00`)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(timeStr) {
|
||||||
|
const normalizedTime = toTimeInputValue(timeStr)
|
||||||
|
|
||||||
|
if (!normalizedTime) return ''
|
||||||
|
|
||||||
|
const [hours, minutes] = normalizedTime.split(':').map(Number)
|
||||||
|
|
||||||
|
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return normalizedTime
|
||||||
|
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(hours, minutes, 0, 0)
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateInputValue(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
return String(dateStr).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTimeInputValue(timeStr) {
|
||||||
|
if (!timeStr) return ''
|
||||||
|
return String(timeStr).slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExpiryStatus(expiryDate) {
|
||||||
|
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const normalizedDate = toDateInputValue(expiryDate)
|
||||||
|
const expiry = new Date(`${normalizedDate}T00:00:00`)
|
||||||
|
|
||||||
|
if (Number.isNaN(expiry.getTime())) {
|
||||||
|
return { status: 'Unknown', color: '#999', text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (daysUntilExpiry < 0) {
|
||||||
|
return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` }
|
||||||
|
} else if (daysUntilExpiry === 0) {
|
||||||
|
return { status: 'Today', color: '#ff9800', days: 0, text: 'Expires today' }
|
||||||
|
} else if (daysUntilExpiry <= 7) {
|
||||||
|
return { status: 'Soon', color: '#ff9800', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` }
|
||||||
|
} else {
|
||||||
|
return { status: 'Fresh', color: '#4CAF50', days: daysUntilExpiry, text: `Expires in ${daysUntilExpiry} days` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(amount, amountType = '') {
|
||||||
|
if (amount == null || amount === '') return 'Amount not set'
|
||||||
|
|
||||||
|
const unit = amountType?.trim() ?? ''
|
||||||
|
return unit ? `${amount} ${unit}` : String(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterInventoryItems(items, filters = {}) {
|
||||||
|
const {
|
||||||
|
locationId = '',
|
||||||
|
minAmount = 0,
|
||||||
|
maxAmount = Infinity,
|
||||||
|
minExpiryDate = '',
|
||||||
|
maxExpiryDate = '',
|
||||||
|
} = filters
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemLocationId = item.locationId ?? item.location?.id ?? ''
|
||||||
|
const locationMatch = !locationId || itemLocationId === locationId
|
||||||
|
|
||||||
|
const numericAmount = item.amount == null ? 0 : Number(item.amount)
|
||||||
|
const amountMatch = numericAmount >= minAmount && numericAmount <= maxAmount
|
||||||
|
|
||||||
|
const normalizedExpiryDate = toDateInputValue(item.expiryDate)
|
||||||
|
let expiryMatch = true
|
||||||
|
|
||||||
|
if (minExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate < minExpiryDate)) {
|
||||||
|
expiryMatch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxExpiryDate && (!normalizedExpiryDate || normalizedExpiryDate > maxExpiryDate)) {
|
||||||
|
expiryMatch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return locationMatch && amountMatch && expiryMatch
|
||||||
|
})
|
||||||
|
}
|
||||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user