Refactored Barcode Page and made fixes to logic
This commit is contained in:
556
README.md
556
README.md
@@ -1,353 +1,313 @@
|
||||
# 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
|
||||
- **Inventory Demo** — Display items using reusable item components
|
||||
- **Advanced Search** — Filter inventory by name, location, quantity range, and expiry date
|
||||
- **Barcode Scanner** — Scan barcodes using camera or keyboard (hardware scanner support)
|
||||
- **Data Visualization** — Pie chart showing inventory distribution
|
||||
- **Responsive Design** — Works on desktop, tablet, and mobile devices
|
||||
- Replaced local demo inventory data with live API calls.
|
||||
- Added a shared API client with JWT handling, refresh-token retry, and normalized error messages.
|
||||
- Added shared auth/session state with `localStorage` persistence.
|
||||
- Updated the dashboard to support login and live inventory summary data.
|
||||
- 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
|
||||
- `index.html` — Homepage with item component demo and inventory pie chart
|
||||
- `search.html` — Search and filter inventory by name, location, quantity, and expiry date
|
||||
- `barcode.html` — Barcode scanner with camera and keyboard input modes
|
||||
- `pantry.html` — Pantry inventory container
|
||||
- `fridge.html` — Fridge inventory container
|
||||
- `freezer.html` — Freezer inventory container
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. |
|
||||
| `/admin` | Household administration page for listing, creating, editing, inviting, and leaving households. |
|
||||
| `/inventory` | Manage locations and inventory items. |
|
||||
| `/search` | Search items and locations, then filter results further in the UI. |
|
||||
| `/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
|
||||
- `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
|
||||
Routing is defined in `src/App.jsx`.
|
||||
|
||||
### Documentation
|
||||
- `ITEM_COMPONENT.md` — Detailed documentation for the item component factory
|
||||
- `README.md` — This file
|
||||
## API Endpoint Map
|
||||
|
||||
## Features Overview
|
||||
### Dashboard (`src/pages/HomePage/HomePage.jsx`)
|
||||
|
||||
### 🏠 Homepage (`index.html`)
|
||||
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
|
||||
When signed out:
|
||||
|
||||
**Sample Data:**
|
||||
- Pantry: 104 items
|
||||
- Fridge: 30 items
|
||||
- Freezer: 87 items
|
||||
- `POST /api/auth/login`
|
||||
|
||||
### 🔍 Search Page (`search.html`)
|
||||
Advanced inventory search and filtering interface.
|
||||
When signed in:
|
||||
|
||||
**Features:**
|
||||
- Search by item name (case-insensitive)
|
||||
- Filter by storage location (All, Pantry, Fridge, Freezer)
|
||||
- Filter by quantity range (min/max values)
|
||||
- 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)
|
||||
- **Pagination** — Results displayed with configurable items per page (15, 30, or 60)
|
||||
- Reset button to clear all filters
|
||||
- Keyboard support (press Enter to search)
|
||||
- Date range validation (start date ≤ end date)
|
||||
- `GET /api/locations`
|
||||
- `GET /api/inventoryitems`
|
||||
|
||||
**Pagination Features:**
|
||||
- **Items Per Page Dropdown** — Choose between 15, 30, or 60 items per page
|
||||
- **Previous/Next Navigation** — Browse through pages of results
|
||||
- **Page Information** — Shows current page and total pages (e.g., "Page 2 of 5")
|
||||
- **Result Summary** — Displays showing item count (e.g., "Found 47 items (16-30)")
|
||||
- **Smart Boundaries** — Previous button disabled on page 1, Next disabled on last page
|
||||
- **Auto-Reset** — Returns to page 1 when search filters change
|
||||
- **Dynamic Page Count** — Automatically updates total pages when page size changes
|
||||
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`)
|
||||
|
||||
**Expiry Date Features:**
|
||||
- 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
|
||||
Locations:
|
||||
|
||||
**Sample Usage:**
|
||||
```
|
||||
Search for items expiring between March 15 and April 15
|
||||
Results show all items with expiry dates in that range
|
||||
Items display formatted dates and expiry status badges
|
||||
- `GET /api/locations`
|
||||
- `POST /api/locations`
|
||||
- `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`
|
||||
|
||||
Notes:
|
||||
|
||||
- Household creation is limited to users with the backend `Admin` role.
|
||||
- Household editing and member invites are available when the current user is a household admin or site admin.
|
||||
|
||||
### 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 still implemented in the client helper, but there is currently no registration form in the UI.
|
||||
- If you need to create a user, use the backend Swagger UI or another client to call `POST /api/auth/register`.
|
||||
|
||||
## 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(/\/$/, '')
|
||||
```
|
||||
|
||||
### 📱 Barcode Scanner (`barcode.html`)
|
||||
Dual-mode barcode scanning interface with keyboard input and camera scanning.
|
||||
That means:
|
||||
|
||||
**Features:**
|
||||
- If `VITE_API_BASE_URL` is set, the frontend uses that value.
|
||||
- Otherwise it defaults to `http://localhost:5000` to match the C# API repo's `appsettings.json`.
|
||||
|
||||
#### Keyboard Input Mode
|
||||
- Type or paste barcode values
|
||||
- Hardware barcode scanner device support
|
||||
- Real-time console logging
|
||||
- Auto-focus field for seamless entry
|
||||
- Enter key to scan
|
||||
### Recommended Override
|
||||
|
||||
#### Camera Scanning Mode (**NEW**)
|
||||
- **Real-time barcode detection** using device camera
|
||||
- **Supported formats:** UPC-A, UPC-E, EAN-13, EAN-8, Code-128, Code-39, and more
|
||||
- **Browser-based detection** (uses Quagga2 library - local version)
|
||||
- **Mode toggle** between keyboard and camera input
|
||||
- **Camera controls** with start/stop buttons
|
||||
- **Real-time feedback** showing detected barcodes
|
||||
- **Error handling** with user-friendly messages
|
||||
- **Mobile support** — Works on iOS and Android devices with camera access
|
||||
- **Console logging** of all detections with metadata
|
||||
Create a `.env.local` file in the project root:
|
||||
|
||||
**Camera Usage:**
|
||||
1. Switch to "Camera Scan" mode by clicking the button
|
||||
2. Click "Start Camera" to begin
|
||||
3. Browser will request camera permission (grant access)
|
||||
4. Position barcode in front of camera
|
||||
5. Barcode is detected automatically in real-time
|
||||
6. Detected barcodes logged to console (press F12)
|
||||
7. Click "Stop Camera" to end scanning
|
||||
|
||||
**Keyboard Usage:**
|
||||
1. Stay in "Keyboard Input" mode
|
||||
2. Type barcode value or paste from clipboard
|
||||
3. Press Enter to complete scan
|
||||
4. Barcode logged to console
|
||||
|
||||
**Console Output Format:**
|
||||
```
|
||||
[Barcode Scanned] 14:07:32.456 | Barcode: 5901234123457 | Input: keyboard
|
||||
[Barcode Scanned] 14:07:45.123 | Barcode: 123456789012 | Input: camera
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:5000
|
||||
```
|
||||
|
||||
**Browser Support:**
|
||||
- Chrome 53+, Firefox 55+, Safari 11+, Edge 79+
|
||||
- Camera access requires HTTPS or localhost
|
||||
- Mobile browsers support camera scanning
|
||||
Example for a different backend host:
|
||||
|
||||
**Testing Instructions:**
|
||||
1. Navigate to the Barcode Scanner page via navbar
|
||||
2. Try keyboard input mode: Type a barcode and press Enter
|
||||
3. Try camera mode: Switch to "Camera Scan" and point at a barcode
|
||||
4. Press F12 to open DevTools Console
|
||||
5. View logged barcodes with timestamps and input type
|
||||
```env
|
||||
VITE_API_BASE_URL=https://your-api-host.example.com
|
||||
```
|
||||
|
||||
### 📦 Storage Location Pages
|
||||
- `pantry.html` — Pantry inventory (expandable for displaying specific items)
|
||||
- `fridge.html` — Fridge inventory
|
||||
- `freezer.html` — Freezer inventory
|
||||
After changing the env file, restart the Vite dev server.
|
||||
|
||||
These pages are currently placeholder containers ready for future development (e.g., displaying items specific to each location).
|
||||
## Auth and Session Behavior
|
||||
|
||||
## Quick Start
|
||||
Auth/session logic lives in:
|
||||
|
||||
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).
|
||||
- `src/api/client.js`
|
||||
- `src/context/AuthContext.jsx`
|
||||
|
||||
### Python 3 (Recommended)
|
||||
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
|
||||
# From the project root
|
||||
python -m http.server 8000
|
||||
|
||||
# Then open http://localhost:8000 in your browser
|
||||
npm install
|
||||
```
|
||||
|
||||
### Alternative Options
|
||||
### 2. Make sure the backend API is running
|
||||
|
||||
- **VS Code Live Server Extension** — Right-click `index.html` → "Open with Live Server"
|
||||
- **Node.js http-server** — `npx http-server`
|
||||
- **Any static file server**
|
||||
Default expected API URL:
|
||||
|
||||
## Usage
|
||||
|
||||
### 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 with camera and keyboard modes
|
||||
- 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
|
||||
|
||||
#### Keyboard Mode
|
||||
1. Open the Barcode Scanner page from the navbar
|
||||
2. Make sure "Keyboard Input" mode is selected
|
||||
3. Click in the input field (auto-focused)
|
||||
4. Enter a barcode:
|
||||
- Type manually and press Enter
|
||||
- Scan with a barcode scanner device
|
||||
- Paste a value (Ctrl/Cmd+V)
|
||||
5. Press F12 to open Developer Tools Console
|
||||
6. View scanned barcodes in the Console tab with metadata
|
||||
|
||||
#### Camera Mode
|
||||
1. Open the Barcode Scanner page from the navbar
|
||||
2. Click "Camera Scan" to switch to camera mode
|
||||
3. Click "Start Camera"
|
||||
4. Browser will request camera permission (grant access)
|
||||
5. Position barcode in front of camera lens
|
||||
6. Barcode is detected automatically and displayed
|
||||
7. Press F12 to view console logs
|
||||
8. Click "Stop Camera" to end
|
||||
|
||||
**Tips:**
|
||||
- Ensure good lighting for better barcode detection
|
||||
- Hold barcode steady and clearly in frame
|
||||
- Device must have a working camera
|
||||
- Camera mode requires HTTPS or localhost
|
||||
|
||||
### 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>
|
||||
```text
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
## Customizing and Extending
|
||||
If you use a different URL, set `VITE_API_BASE_URL` in `.env.local`.
|
||||
|
||||
### Adding New Items to Search
|
||||
Edit `search.js` and add items to the `inventoryData` array:
|
||||
```javascript
|
||||
export const inventoryData = [
|
||||
{
|
||||
id: 21,
|
||||
name: 'Coffee',
|
||||
location: 'Pantry',
|
||||
quantity: 2,
|
||||
unit: 'bags',
|
||||
expiryDate: '2026-06-15',
|
||||
img: 'https://picsum.photos/seed/coffee/200/200'
|
||||
},
|
||||
// ... more items
|
||||
];
|
||||
### 3. Start the frontend
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Date Format:** Use ISO 8601 format (YYYY-MM-DD) for expiry dates.
|
||||
### 4. Build for production
|
||||
|
||||
### Styling
|
||||
- Override styles in `main.css` for global changes
|
||||
- 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>
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Update `navbar.js` to add a link to your new page:
|
||||
```javascript
|
||||
<li><a href="yourpage.html" data-route="yourpage.html">Your Page</a></li>
|
||||
### 5. Preview the production build
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Converting to Production
|
||||
- Replace placeholder images with real inventory photos
|
||||
- Implement backend storage (currently using static data)
|
||||
- Add user authentication
|
||||
- Integrate with real barcode/UPC database
|
||||
- Deploy camera barcode detection to server (for performance optimization)
|
||||
- Consider a frontend framework (React, Vue, etc.) for scale
|
||||
- Add unit/integration tests
|
||||
- Set up CI/CD pipeline
|
||||
- Implement database for persistent storage and inventory tracking
|
||||
## Using the App
|
||||
|
||||
## Technical Notes
|
||||
### Login
|
||||
|
||||
### Barcode Scanning Library
|
||||
- **Library:** Quagga2 (local version v1.12.1)
|
||||
- **Location:** `./quagga2/quagga2-1.12.1/docs/examples/dist/quagga.min.js` (153 KB)
|
||||
- **Why Quagga2:** Improved barcode detection accuracy and performance compared to original Quagga
|
||||
- **Supported Formats:** UPC-A, UPC-E, EAN-13, EAN-8, Code-128, Code-39
|
||||
- **API:** Backward compatible with original Quagga.js; no code changes needed
|
||||
- **Initialization:** See `barcode.html` lines 268-290 for camera initialization logic
|
||||
1. Open `/`
|
||||
2. Enter an existing user email and password
|
||||
3. Sign in to unlock the protected routes and API-backed data
|
||||
|
||||
### Camera Access
|
||||
- Requires browser permission (user must grant access)
|
||||
- Works over HTTPS or localhost only (security requirement)
|
||||
- Uses HTML5 MediaDevices API (`getUserMedia`)
|
||||
- Video constraints: 800x600 resolution, environment-facing camera
|
||||
- Real-time frame processing for barcode detection
|
||||
There is no registration form in the UI at the moment.
|
||||
|
||||
### Performance Considerations
|
||||
- Camera scanning is CPU-intensive on older devices
|
||||
- Frame processing happens in real-time on main thread
|
||||
- Consider WebWorkers for production optimization
|
||||
- Network-free operation: all detection happens client-side
|
||||
### Inventory Page
|
||||
|
||||
## Contributing
|
||||
Use `/inventory` to:
|
||||
|
||||
Contributions are welcome! Suggested improvements:
|
||||
- Create, edit, and delete locations
|
||||
- Create, edit, and delete inventory items
|
||||
- Load a single inventory item before editing
|
||||
|
||||
- [ ] Add unit and visual tests
|
||||
- [ ] 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>`)
|
||||
- [ ] Optimize camera barcode detection for performance
|
||||
- [ ] Add more barcode format support
|
||||
- [ ] Add email/notification alerts for items expiring soon
|
||||
- [ ] Implement CSV import/export for inventory
|
||||
Important update behavior:
|
||||
|
||||
## License
|
||||
- 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.
|
||||
|
||||
Use as you like; no license file is included by default.
|
||||
### Admin Page
|
||||
|
||||
Use `/admin` to:
|
||||
|
||||
- Review all households returned for the current user
|
||||
- Create a new household when the signed-in user has the site admin role
|
||||
- Edit households the signed-in user administers
|
||||
- Invite members by email to the selected household
|
||||
- Leave a household from the same page
|
||||
|
||||
### 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.
|
||||
|
||||
314
src/App.css
314
src/App.css
@@ -1,3 +1,125 @@
|
||||
: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 {
|
||||
@@ -13,10 +135,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* General Styling */
|
||||
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 {
|
||||
@@ -32,45 +176,189 @@ h3 {
|
||||
hr {
|
||||
border: 0;
|
||||
height: 2px;
|
||||
background: #5b5b5b;
|
||||
background: var(--color-rule);
|
||||
margin: 20px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Shared button styles */
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
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: #4CAF50;
|
||||
color: white;
|
||||
background-color: var(--button-primary-bg);
|
||||
color: var(--button-primary-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #45a049;
|
||||
background-color: var(--button-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #ddd;
|
||||
color: #333;
|
||||
background-color: var(--button-secondary-bg);
|
||||
color: var(--button-secondary-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #bbb;
|
||||
background-color: var(--button-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
background-color: var(--button-danger-bg);
|
||||
color: var(--button-danger-text);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #da190b;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
58
src/App.jsx
58
src/App.jsx
@@ -1,19 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { 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 SearchPage from './pages/SearchPage/SearchPage.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 } = 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 />
|
||||
<Navbar theme={theme} onToggleTheme={toggleTheme} />
|
||||
<div id="page-content">
|
||||
{isAuthenticated ? (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/inventory" element={<InventoryPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/barcode" element={<BarcodePage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<HomePage />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
401
src/api/client.js
Normal file
401
src/api/client.js
Normal file
@@ -0,0 +1,401 @@
|
||||
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')
|
||||
},
|
||||
}
|
||||
|
||||
export const locationsApi = {
|
||||
getLocations() {
|
||||
return requestJson('/api/locations')
|
||||
},
|
||||
|
||||
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')
|
||||
},
|
||||
|
||||
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')
|
||||
},
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
gap: 12px;
|
||||
font-family: inherit;
|
||||
padding: 10px;
|
||||
border: 1px solid #e6e6e6;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.item-component__img {
|
||||
@@ -38,7 +38,7 @@
|
||||
.item-component__line {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #222;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.item-component__line--title {
|
||||
@@ -46,12 +46,12 @@
|
||||
}
|
||||
|
||||
.item-component__line--subtitle {
|
||||
color: #555;
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.item-component__line--desc {
|
||||
color: #666;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -60,8 +60,16 @@
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
font: inherit;
|
||||
border: 1px solid #ccc;
|
||||
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) {
|
||||
|
||||
@@ -2,24 +2,102 @@ nav ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #333333;
|
||||
background-color: var(--nav-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
display: block;
|
||||
color: white;
|
||||
padding: 14px 16px;
|
||||
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 ul li a:hover {
|
||||
background-color: #111111;
|
||||
background-color: var(--nav-bg-hover);
|
||||
}
|
||||
|
||||
nav ul li a.active {
|
||||
background-color: #111111;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import './Navbar.css'
|
||||
|
||||
function Navbar() {
|
||||
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 nextThemeLabel = theme === 'dark' ? 'Switch to light' : 'Switch to dark'
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<li><NavLink to="/">Homepage</NavLink></li>
|
||||
<li><NavLink to="/admin">Admin</NavLink></li>
|
||||
<li><NavLink to="/inventory">Inventory</NavLink></li>
|
||||
<li><NavLink to="/search">Search</NavLink></li>
|
||||
<li><NavLink to="/barcode">Barcode Scanner</NavLink></li>
|
||||
{isSiteAdmin && <li><NavLink to="/users">Users</NavLink></li>}
|
||||
</>
|
||||
) : (
|
||||
<li><NavLink to="/">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 ? user?.email ?? 'Signed in' : 'Signed out'}</span>
|
||||
{isAuthenticated && (
|
||||
<button type="button" className="nav-button" onClick={logout}>Sign out</button>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: sans-serif;
|
||||
font-family: inherit;
|
||||
color: var(--color-text);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -13,7 +14,7 @@ circle {
|
||||
}
|
||||
|
||||
.legend {
|
||||
font-family: sans-serif;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
@@ -20,7 +20,7 @@ function PieChart({ 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="#f2f2f2" strokeWidth="20" />
|
||||
<circle cx="21" cy="21" r={RADIUS} fill="transparent" stroke="var(--color-chart-track)" strokeWidth="20" />
|
||||
{computedSegments.map((seg, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
|
||||
81
src/context/AuthContext.jsx
Normal file
81
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
authApi,
|
||||
getStoredSession,
|
||||
profileApi,
|
||||
saveSession,
|
||||
subscribeToSessionChanges,
|
||||
} from '../api/client.js'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [session, setSession] = useState(() => getStoredSession())
|
||||
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
|
||||
|
||||
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) {
|
||||
saveSession({
|
||||
...existingSession,
|
||||
user: 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.includes('Admin'),
|
||||
initializing,
|
||||
login: authApi.login,
|
||||
register: authApi.register,
|
||||
logout: authApi.logout,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,12 +2,15 @@ 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>,
|
||||
)
|
||||
|
||||
86
src/pages/AdminPage/AdminPage.css
Normal file
86
src/pages/AdminPage/AdminPage.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.member-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
427
src/pages/AdminPage/AdminPage.jsx
Normal file
427
src/pages/AdminPage/AdminPage.jsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { householdsApi } 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: '',
|
||||
}
|
||||
|
||||
function formatMemberName(member) {
|
||||
const fullName = [member.firstName, member.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return fullName || member.email || 'Unnamed member'
|
||||
}
|
||||
|
||||
function AdminPage() {
|
||||
const { isAuthenticated, isSiteAdmin } = 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('')
|
||||
|
||||
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('')
|
||||
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])
|
||||
|
||||
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
|
||||
|
||||
function resetHouseholdEditor() {
|
||||
setEditingHouseholdId('')
|
||||
setHouseholdForm(EMPTY_HOUSEHOLD_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 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">Syncing household data...</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>
|
||||
</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>{formatMemberName(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
|
||||
@@ -1,260 +1,353 @@
|
||||
.scanner-container {
|
||||
background: #f9f9f9;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 30px auto;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
.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 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scanner-header h3 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scanner-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 10px 0 0 0;
|
||||
.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: flex;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
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-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-color: #4CAF50;
|
||||
background: var(--button-primary-bg);
|
||||
border-color: var(--button-primary-bg);
|
||||
color: var(--button-primary-text);
|
||||
}
|
||||
|
||||
.mode-btn:hover:not(.active) {
|
||||
border-color: #4CAF50;
|
||||
.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-section {
|
||||
display: none;
|
||||
.camera-frame.is-active {
|
||||
background: var(--color-video-bg);
|
||||
}
|
||||
|
||||
.camera-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.keyboard-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#video {
|
||||
.camera-target,
|
||||
.camera-target video,
|
||||
.camera-target canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
margin-bottom: 15px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.camera-controls {
|
||||
display: flex;
|
||||
.camera-target video,
|
||||
.camera-target canvas {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.barcode-input.highlight {
|
||||
border-color: #4CAF50;
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--status-info-bg);
|
||||
border: 1px solid var(--status-info-border);
|
||||
color: var(--status-info-text);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.camera-controls,
|
||||
.manual-entry-actions,
|
||||
.quick-actions,
|
||||
.lookup-result-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.open-console {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.open-console:hover {
|
||||
background-color: #0b7dda;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #ffebee;
|
||||
border: 1px solid #f5a5ac;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
color: #c62828;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detected-barcode {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.detected-barcode.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detected-barcode-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.scanner-container {
|
||||
padding: 20px;
|
||||
margin: 20px 10px;
|
||||
.manual-entry-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.barcode-input {
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
.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);
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
width: 100%;
|
||||
.scan-summary-label {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
.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;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
.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.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%;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,73 @@
|
||||
/* HomePage-specific styles (if needed) */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,221 @@
|
||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||
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 demoItems = [
|
||||
{ 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' },
|
||||
]
|
||||
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
|
||||
|
||||
function getRandomColor() {
|
||||
const letters = '0123456789ABCDEF'
|
||||
let color = '#'
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 10)]
|
||||
}
|
||||
return color
|
||||
const INITIAL_LOGIN_FORM = {
|
||||
email: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
const pieSegments = [
|
||||
{ label: 'Pantry', value: 104, color: getRandomColor() },
|
||||
{ label: 'Fridge', value: 30, color: getRandomColor() },
|
||||
{ label: 'Freezer', value: 87, color: getRandomColor() },
|
||||
]
|
||||
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 (
|
||||
<>
|
||||
<h2>Homepage</h2>
|
||||
{!isLoginView && (
|
||||
<>
|
||||
<h2>Dashboard</h2>
|
||||
<hr />
|
||||
<h3>To use</h3>
|
||||
<div className="item-component-grid">
|
||||
{demoItems.map((item, i) => (
|
||||
<ItemCard key={i} {...item} editable={false} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<hr />
|
||||
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
527
src/pages/InventoryPage/InventoryPage.jsx
Normal file
527
src/pages/InventoryPage/InventoryPage.jsx
Normal file
@@ -0,0 +1,527 @@
|
||||
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 [editingLocationId, setEditingLocationId] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState('')
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
|
||||
const [itemForm, setItemForm] = useState(createItemForm())
|
||||
|
||||
async function loadPageData() {
|
||||
const [nextLocations, nextItems] = await Promise.all([
|
||||
locationsApi.getLocations(),
|
||||
inventoryApi.getInventoryItems(),
|
||||
])
|
||||
|
||||
setLocations(nextLocations)
|
||||
setItems(nextItems)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadInitialData() {
|
||||
if (!isAuthenticated) {
|
||||
setLocations([])
|
||||
setItems([])
|
||||
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) {
|
||||
setLocations(nextLocations)
|
||||
setItems(nextItems)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(error.message)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadInitialData()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
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()
|
||||
|
||||
if (!name) {
|
||||
setErrorMessage('Item name is required.')
|
||||
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 ${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={() => {
|
||||
setEditingLocationId(location.id)
|
||||
setLocationForm({
|
||||
name: location.name,
|
||||
description: location.description ?? '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</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>{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"
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</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
|
||||
@@ -1,7 +1,4 @@
|
||||
.search-container {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@@ -19,23 +16,25 @@
|
||||
|
||||
.form-group label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
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: #4CAF50;
|
||||
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
|
||||
border-color: var(--button-primary-bg);
|
||||
box-shadow: 0 0 5px var(--button-primary-ring);
|
||||
}
|
||||
|
||||
.quantity-range,
|
||||
@@ -53,19 +52,40 @@
|
||||
.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: #666;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -79,18 +99,18 @@
|
||||
}
|
||||
|
||||
.expiry-fresh {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
background-color: var(--status-success-bg);
|
||||
color: var(--status-success-text);
|
||||
}
|
||||
|
||||
.expiry-soon {
|
||||
background-color: #fff3e0;
|
||||
color: #e65100;
|
||||
background-color: var(--status-warning-bg);
|
||||
color: var(--status-warning-text);
|
||||
}
|
||||
|
||||
.expiry-expired {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error-text);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
@@ -110,21 +130,22 @@
|
||||
|
||||
.pagination-size label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.pagination-size select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
background-color: var(--color-input-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-size select:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
border-color: var(--button-primary-bg);
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
@@ -135,9 +156,9 @@
|
||||
|
||||
.pagination-nav button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
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;
|
||||
@@ -145,9 +166,9 @@
|
||||
}
|
||||
|
||||
.pagination-nav button:hover:not(:disabled) {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-color: #4CAF50;
|
||||
background-color: var(--button-primary-bg);
|
||||
color: var(--button-primary-text);
|
||||
border-color: var(--button-primary-bg);
|
||||
}
|
||||
|
||||
.pagination-nav button:disabled {
|
||||
@@ -157,12 +178,15 @@
|
||||
|
||||
.pagination-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
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;
|
||||
|
||||
@@ -1,58 +1,176 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
||||
import { searchInventory, getLocations, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
|
||||
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'
|
||||
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)}
|
||||
{formatDate(expiryDate) || 'No expiry date'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPage() {
|
||||
const locations = useMemo(() => getLocations(), [])
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [searchName, setSearchName] = useState('')
|
||||
const [location, setLocation] = useState('All')
|
||||
const [minQuantity, setMinQuantity] = useState('0')
|
||||
const [maxQuantity, setMaxQuantity] = useState('999')
|
||||
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 [results, setResults] = useState(() => searchInventory())
|
||||
const [locations, setLocations] = useState([])
|
||||
const [matchingLocations, setMatchingLocations] = useState([])
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
function performSearch() {
|
||||
const minQty = parseInt(minQuantity) || 0
|
||||
const maxQty = parseInt(maxQuantity) || Infinity
|
||||
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
|
||||
}
|
||||
|
||||
setCurrentPage(1)
|
||||
setResults(searchInventory(searchName, location, minQty, maxQty, minExpiryDate, maxExpiryDate))
|
||||
if (safeMinAmount > safeMaxAmount) {
|
||||
alert('Minimum amount cannot be greater than maximum amount')
|
||||
return
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
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('')
|
||||
setLocation('All')
|
||||
setMinQuantity('0')
|
||||
setMaxQuantity('999')
|
||||
setLocationId('')
|
||||
setMinAmount('0')
|
||||
setMaxAmount('999')
|
||||
setMinExpiryDate('')
|
||||
setMaxExpiryDate('')
|
||||
setCurrentPage(1)
|
||||
setPageSize(15)
|
||||
setResults(searchInventory())
|
||||
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)
|
||||
@@ -62,75 +180,92 @@ function SearchPage() {
|
||||
<h2>Search Inventory</h2>
|
||||
<hr />
|
||||
|
||||
<div className="search-container">
|
||||
{!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 Name:</label>
|
||||
<label htmlFor="search-name">Item / location query</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search-name"
|
||||
placeholder="e.g., Milk, Pasta, Chicken..."
|
||||
placeholder="milk, fridge, 01234567890..."
|
||||
autoComplete="off"
|
||||
value={searchName}
|
||||
onChange={e => setSearchName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') performSearch() }}
|
||||
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={location} onChange={e => setLocation(e.target.value)}>
|
||||
{locations.map(loc => (
|
||||
<option key={loc} value={loc}>{loc}</option>
|
||||
<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>Quantity Range:</label>
|
||||
<label>Amount range</label>
|
||||
<div className="quantity-range">
|
||||
<div className="form-group">
|
||||
<label htmlFor="min-quantity" style={{ fontSize: '12px' }}>Min:</label>
|
||||
<label htmlFor="min-amount" style={{ fontSize: '12px' }}>Min</label>
|
||||
<input
|
||||
type="number"
|
||||
id="min-quantity"
|
||||
id="min-amount"
|
||||
min="0"
|
||||
value={minQuantity}
|
||||
onChange={e => setMinQuantity(e.target.value)}
|
||||
step="0.1"
|
||||
value={minAmount}
|
||||
onChange={event => setMinAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="max-quantity" style={{ fontSize: '12px' }}>Max:</label>
|
||||
<label htmlFor="max-amount" style={{ fontSize: '12px' }}>Max</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max-quantity"
|
||||
id="max-amount"
|
||||
min="0"
|
||||
value={maxQuantity}
|
||||
onChange={e => setMaxQuantity(e.target.value)}
|
||||
step="0.1"
|
||||
value={maxAmount}
|
||||
onChange={event => setMaxAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Expiry Date Range:</label>
|
||||
<label>Expiry date range</label>
|
||||
<div className="date-range">
|
||||
<div className="form-group">
|
||||
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start Date:</label>
|
||||
<label htmlFor="min-expiry-date" style={{ fontSize: '12px' }}>Start date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="min-expiry-date"
|
||||
value={minExpiryDate}
|
||||
onChange={e => setMinExpiryDate(e.target.value)}
|
||||
onChange={event => setMinExpiryDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End Date:</label>
|
||||
<label htmlFor="max-expiry-date" style={{ fontSize: '12px' }}>End date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="max-expiry-date"
|
||||
value={maxExpiryDate}
|
||||
onChange={e => setMaxExpiryDate(e.target.value)}
|
||||
onChange={event => setMaxExpiryDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +278,25 @@ function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="results-container">
|
||||
{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'
|
||||
@@ -157,7 +310,10 @@ function SearchPage() {
|
||||
<select
|
||||
id="page-size-select"
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(parseInt(e.target.value)); setCurrentPage(1) }}
|
||||
onChange={event => {
|
||||
setPageSize(parseInt(event.target.value, 10))
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="15">15</option>
|
||||
<option value="30">30</option>
|
||||
@@ -169,9 +325,9 @@ function SearchPage() {
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={paginationData.currentPage === 1}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
>
|
||||
← Previous
|
||||
Previous
|
||||
</button>
|
||||
<div className="pagination-info">
|
||||
Page {paginationData.currentPage} of {paginationData.totalPages}
|
||||
@@ -180,9 +336,9 @@ function SearchPage() {
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={paginationData.currentPage === paginationData.totalPages}
|
||||
onClick={() => setCurrentPage(p => p + 1)}
|
||||
onClick={() => setCurrentPage(page => page + 1)}
|
||||
>
|
||||
Next →
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,11 +350,10 @@ function SearchPage() {
|
||||
{paginationData.items.map(item => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
imgSrc={item.img}
|
||||
imgAlt={item.name}
|
||||
text1={item.name}
|
||||
text2={`${item.location} — ${item.quantity} ${item.unit}`}
|
||||
text3={`Expires: ${formatDate(item.expiryDate)}`}
|
||||
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} />
|
||||
@@ -208,6 +363,8 @@ function SearchPage() {
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
47
src/pages/UsersPage/UsersPage.css
Normal file
47
src/pages/UsersPage/UsersPage.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.users-page-panel {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.users-page-note {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.users-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid var(--color-border-muted);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
126
src/pages/UsersPage/UsersPage.jsx
Normal file
126
src/pages/UsersPage/UsersPage.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usersApi } from '../../api/client.js'
|
||||
import { useAuth } from '../../context/AuthContext.jsx'
|
||||
import './UsersPage.css'
|
||||
|
||||
function formatUserName(user) {
|
||||
const fullName = [user.firstName, user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return fullName || user.email || 'Unnamed user'
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const { isAuthenticated, isSiteAdmin } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [statusMessage, setStatusMessage] = useState('')
|
||||
const [endpointUnavailable, setEndpointUnavailable] = useState(false)
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
|
||||
try {
|
||||
const response = await usersApi.getUsers()
|
||||
setUsers(Array.isArray(response) ? response : [])
|
||||
setEndpointUnavailable(false)
|
||||
} catch (error) {
|
||||
setUsers([])
|
||||
|
||||
if (error.status === 404) {
|
||||
setEndpointUnavailable(true)
|
||||
setStatusMessage('GET /api/users is not available in the current backend build yet.')
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isSiteAdmin) {
|
||||
setUsers([])
|
||||
setErrorMessage('')
|
||||
setStatusMessage('')
|
||||
setEndpointUnavailable(false)
|
||||
return
|
||||
}
|
||||
|
||||
loadUsers()
|
||||
}, [isAuthenticated, isSiteAdmin])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Users</h2>
|
||||
<hr />
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<div className="auth-required">
|
||||
Sign in on the dashboard before using the users endpoint.
|
||||
</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--info">{statusMessage}</div>}
|
||||
{loading && <div className="status-banner status-banner--info">Loading users...</div>}
|
||||
|
||||
<section className="panel users-page-panel">
|
||||
<div className="section-heading">
|
||||
<h3>User Directory</h3>
|
||||
<button type="button" className="btn btn-secondary" onClick={loadUsers}>
|
||||
Refresh users
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="form-note users-page-note">
|
||||
This page is intentionally minimal. It is ready to consume a backend users endpoint as soon as that contract exists.
|
||||
</p>
|
||||
|
||||
{endpointUnavailable ? (
|
||||
<div className="empty-state compact-empty-state">
|
||||
Add `GET /api/users` to the backend to populate this page.
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state compact-empty-state">No users were returned.</div>
|
||||
) : (
|
||||
<div className="users-grid">
|
||||
{users.map(user => {
|
||||
const roles = Array.isArray(user.roles) ? user.roles : []
|
||||
|
||||
return (
|
||||
<article className="user-card" key={user.id ?? user.email}>
|
||||
<strong>{formatUserName(user)}</strong>
|
||||
<div className="entity-meta">{user.email || 'No email provided.'}</div>
|
||||
<div className="entity-meta">ID: {user.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>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
name: form.name.trim(),
|
||||
}
|
||||
|
||||
const barcode = normalizeBarcode(form.barcode)
|
||||
const amountType = form.amountType.trim()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { inventoryData } from '../data/inventory.js'
|
||||
|
||||
export function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
|
||||
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 toDateInputValue(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return String(dateStr).slice(0, 10)
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@@ -26,24 +40,40 @@ export function getExpiryStatus(expiryDate) {
|
||||
}
|
||||
}
|
||||
|
||||
export function searchInventory(searchName = '', selectedLocation = 'All', minQuantity = 0, maxQuantity = Infinity, minExpiryDate = '', maxExpiryDate = '') {
|
||||
return inventoryData.filter(item => {
|
||||
const nameMatch = item.name.toLowerCase().includes(searchName.toLowerCase())
|
||||
const locationMatch = selectedLocation === 'All' || item.location === selectedLocation
|
||||
const quantityMatch = item.quantity >= minQuantity && item.quantity <= maxQuantity
|
||||
export function formatAmount(amount, amountType = '') {
|
||||
if (amount == null || amount === '') return 'Amount not set'
|
||||
|
||||
let expiryMatch = true
|
||||
if (minExpiryDate || maxExpiryDate) {
|
||||
const itemExpiry = item.expiryDate
|
||||
if (minExpiryDate && itemExpiry < minExpiryDate) expiryMatch = false
|
||||
if (maxExpiryDate && itemExpiry > maxExpiryDate) expiryMatch = false
|
||||
const unit = amountType?.trim() ?? ''
|
||||
return unit ? `${amount} ${unit}` : String(amount)
|
||||
}
|
||||
|
||||
return nameMatch && locationMatch && quantityMatch && expiryMatch
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export function getLocations() {
|
||||
const locations = [...new Set(inventoryData.map(item => item.location))]
|
||||
return ['All', ...locations.sort()]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user