Update project files
This commit is contained in:
40
README.md
40
README.md
@@ -27,11 +27,11 @@ This app is no longer a static demo. It now uses the backend API for authenticat
|
|||||||
| `/barcode` | Scan a barcode and search the inventory API for matches. |
|
| `/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. |
|
| `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. |
|
||||||
|
|
||||||
Routing is defined in `src/App.jsx`.
|
Routing is defined in `src/App.tsx`.
|
||||||
|
|
||||||
## API Endpoint Map
|
## API Endpoint Map
|
||||||
|
|
||||||
### Dashboard (`src/pages/HomePage/HomePage.jsx`)
|
### Dashboard (`src/pages/HomePage/HomePage.tsx`)
|
||||||
|
|
||||||
When signed out:
|
When signed out:
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ When signed in:
|
|||||||
- `GET /api/locations`
|
- `GET /api/locations`
|
||||||
- `GET /api/inventoryitems`
|
- `GET /api/inventoryitems`
|
||||||
|
|
||||||
### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`)
|
### Inventory (`src/pages/InventoryPage/InventoryPage.tsx`)
|
||||||
|
|
||||||
Locations:
|
Locations:
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ Inventory items:
|
|||||||
- `PUT /api/inventoryitems/{id}`
|
- `PUT /api/inventoryitems/{id}`
|
||||||
- `DELETE /api/inventoryitems/{id}`
|
- `DELETE /api/inventoryitems/{id}`
|
||||||
|
|
||||||
### Admin (`src/pages/AdminPage/AdminPage.jsx`)
|
### Admin (`src/pages/AdminPage/AdminPage.tsx`)
|
||||||
|
|
||||||
Households:
|
Households:
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Notes:
|
|||||||
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
|
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
|
||||||
- The register endpoint does not assign roles, so new users are created without admin access by default.
|
- The register endpoint does not assign roles, so new users are created without admin access by default.
|
||||||
|
|
||||||
### Search (`src/pages/SearchPage/SearchPage.jsx`)
|
### Search (`src/pages/SearchPage/SearchPage.tsx`)
|
||||||
|
|
||||||
Initial page data:
|
Initial page data:
|
||||||
|
|
||||||
@@ -96,13 +96,13 @@ Note:
|
|||||||
- The backend search API only supports the `q` query parameter.
|
- 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.
|
- Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns.
|
||||||
|
|
||||||
### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`)
|
### Barcode (`src/pages/BarcodePage/BarcodePage.tsx`)
|
||||||
|
|
||||||
- `GET /api/search/items?q={barcode}`
|
- `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.
|
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`)
|
### Users (`src/pages/UsersPage/UsersPage.tsx`)
|
||||||
|
|
||||||
- `GET /api/users`
|
- `GET /api/users`
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ Notes:
|
|||||||
- The users page is intentionally minimal and is ready to consume a future users endpoint.
|
- 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`.
|
- 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`)
|
### Shared Auth Support (`src/api/client.ts`)
|
||||||
|
|
||||||
The shared API client also supports:
|
The shared API client also supports:
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ Notes:
|
|||||||
|
|
||||||
## API Configuration
|
## API Configuration
|
||||||
|
|
||||||
The API base URL is configured in `src/api/client.js`.
|
The API base URL is configured in `src/api/client.ts`.
|
||||||
|
|
||||||
Current setup:
|
Current setup:
|
||||||
|
|
||||||
@@ -157,8 +157,8 @@ After changing the env file, restart the Vite dev server.
|
|||||||
|
|
||||||
Auth/session logic lives in:
|
Auth/session logic lives in:
|
||||||
|
|
||||||
- `src/api/client.js`
|
- `src/api/client.ts`
|
||||||
- `src/context/AuthContext.jsx`
|
- `src/context/AuthContext.tsx`
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
@@ -268,15 +268,15 @@ Use `/users` to:
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
|
| `src/api/client.ts` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers |
|
||||||
| `src/context/AuthContext.jsx` | Shared auth/session state for the React app |
|
| `src/context/AuthContext.tsx` | Shared auth/session state for the React app |
|
||||||
| `src/App.jsx` | Route definitions |
|
| `src/App.tsx` | Route definitions |
|
||||||
| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI |
|
| `src/pages/AdminPage/AdminPage.tsx` | Household administration UI |
|
||||||
| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI |
|
| `src/pages/HomePage/HomePage.tsx` | Dashboard and login UI |
|
||||||
| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI |
|
| `src/pages/InventoryPage/InventoryPage.tsx` | Location and inventory CRUD UI |
|
||||||
| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI |
|
| `src/pages/SearchPage/SearchPage.tsx` | API-backed search UI |
|
||||||
| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI |
|
| `src/pages/BarcodePage/BarcodePage.tsx` | Barcode scanning and API lookup UI |
|
||||||
| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view |
|
| `src/pages/UsersPage/UsersPage.tsx` | Users endpoint scaffold and list view |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
62
package-lock.json
generated
62
package-lock.json
generated
@@ -15,7 +15,11 @@
|
|||||||
"react-router-dom": "^7.13.2"
|
"react-router-dom": "^7.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.2.15",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.3"
|
"vite": "^8.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -908,6 +912,36 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
|
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||||
|
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||||
@@ -947,6 +981,13 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cwise-compiler": {
|
"node_modules/cwise-compiler": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
|
||||||
@@ -1598,6 +1639,27 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uniq": {
|
"node_modules/uniq": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "webpage-playground",
|
"name": "webpage-playground",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.",
|
"description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with React, TypeScript, and CSS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -22,7 +23,11 @@
|
|||||||
"react-router-dom": "^7.13.2"
|
"react-router-dom": "^7.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.2.15",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.3"
|
"vite": "^8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Navigate, Routes, Route } from 'react-router-dom'
|
import { Navigate, Routes, Route } from 'react-router-dom'
|
||||||
import Navbar from './components/Navbar/Navbar.jsx'
|
import Navbar from './components/Navbar/Navbar'
|
||||||
import AdminPage from './pages/AdminPage/AdminPage.jsx'
|
import AdminPage from './pages/AdminPage/AdminPage'
|
||||||
import HomePage from './pages/HomePage/HomePage.jsx'
|
import HomePage from './pages/HomePage/HomePage'
|
||||||
import InventoryPage from './pages/InventoryPage/InventoryPage.jsx'
|
import InventoryPage from './pages/InventoryPage/InventoryPage'
|
||||||
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx'
|
import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage'
|
||||||
import ProfilePage from './pages/ProfilePage/ProfilePage.jsx'
|
import ProfilePage from './pages/ProfilePage/ProfilePage'
|
||||||
import SearchPage from './pages/SearchPage/SearchPage.jsx'
|
import SearchPage from './pages/SearchPage/SearchPage'
|
||||||
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx'
|
import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage'
|
||||||
import BarcodePage from './pages/BarcodePage/BarcodePage.jsx'
|
import BarcodePage from './pages/BarcodePage/BarcodePage'
|
||||||
import UsersPage from './pages/UsersPage/UsersPage.jsx'
|
import UsersPage from './pages/UsersPage/UsersPage'
|
||||||
import { useAuth } from './context/AuthContext.jsx'
|
import { useAuth } from './context/AuthContext'
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = 'pantry-theme'
|
const THEME_STORAGE_KEY = 'pantry-theme'
|
||||||
|
export type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
function resolveInitialTheme() {
|
function resolveInitialTheme(): Theme {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 'light'
|
return 'light'
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ if (typeof document !== 'undefined') {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, isSiteAdmin } = useAuth()
|
const { isAuthenticated, isSiteAdmin } = useAuth()
|
||||||
const [theme, setTheme] = useState(initialTheme)
|
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = theme
|
document.documentElement.dataset.theme = theme
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
const SESSION_STORAGE_KEY = 'pantry-management-session'
|
|
||||||
const SESSION_CHANGE_EVENT = 'pantry-management-session-change'
|
|
||||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '')
|
|
||||||
|
|
||||||
let refreshPromise = null
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(message, status = 0, data = null) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'ApiError'
|
|
||||||
this.status = status
|
|
||||||
this.data = data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(path) {
|
|
||||||
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStoredSession(rawValue) {
|
|
||||||
if (!rawValue) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawValue)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchSessionChange(session) {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
|
|
||||||
detail: session,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredSession() {
|
|
||||||
if (typeof window === 'undefined') return null
|
|
||||||
|
|
||||||
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveSession(session) {
|
|
||||||
if (typeof window === 'undefined') return session
|
|
||||||
|
|
||||||
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
|
||||||
dispatchSessionChange(session)
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSession() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
|
||||||
dispatchSessionChange(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeToSessionChanges(listener) {
|
|
||||||
if (typeof window === 'undefined') return () => {}
|
|
||||||
|
|
||||||
function handleCustomEvent(event) {
|
|
||||||
listener(event.detail ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStorageEvent(event) {
|
|
||||||
if (event.key !== SESSION_STORAGE_KEY) return
|
|
||||||
listener(parseStoredSession(event.newValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
|
||||||
window.addEventListener('storage', handleStorageEvent)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
|
||||||
window.removeEventListener('storage', handleStorageEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readResponse(response) {
|
|
||||||
const text = await response.text()
|
|
||||||
|
|
||||||
if (!text) return null
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') ?? ''
|
|
||||||
|
|
||||||
if (contentType.includes('json')) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractErrorMessage(data, fallbackMessage) {
|
|
||||||
if (!data) return fallbackMessage
|
|
||||||
|
|
||||||
if (typeof data === 'string' && data.trim()) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.message === 'string' && data.message.trim()) {
|
|
||||||
return data.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.Message === 'string' && data.Message.trim()) {
|
|
||||||
return data.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.error === 'string' && data.error.trim()) {
|
|
||||||
return data.error
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.errors && typeof data.errors === 'object') {
|
|
||||||
const messages = Object.values(data.errors)
|
|
||||||
.flatMap(value => Array.isArray(value) ? value : [value])
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
if (messages.length > 0) {
|
|
||||||
return messages.join(' ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data.title === 'string' && data.title.trim()) {
|
|
||||||
return data.title
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performRefresh(session) {
|
|
||||||
if (!session?.refreshToken) {
|
|
||||||
clearSession()
|
|
||||||
throw new ApiError('Your session expired. Sign in again.', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
let response
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await fetch(buildUrl('/api/auth/refresh-token'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
accessToken: session.accessToken,
|
|
||||||
refreshToken: session.refreshToken,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
throw new ApiError(
|
|
||||||
'Unable to refresh your session. Make sure the API is running and reachable.',
|
|
||||||
0,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await readResponse(response)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
clearSession()
|
|
||||||
throw new ApiError(
|
|
||||||
extractErrorMessage(data, 'Your session expired. Sign in again.'),
|
|
||||||
response.status,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return saveSession({
|
|
||||||
accessToken: data.accessToken,
|
|
||||||
refreshToken: data.refreshToken,
|
|
||||||
user: data.user ?? session.user ?? null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSession() {
|
|
||||||
if (!refreshPromise) {
|
|
||||||
refreshPromise = performRefresh(getStoredSession())
|
|
||||||
.finally(() => {
|
|
||||||
refreshPromise = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return refreshPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson(path, options = {}) {
|
|
||||||
const {
|
|
||||||
method = 'GET',
|
|
||||||
body,
|
|
||||||
headers = {},
|
|
||||||
skipAuth = false,
|
|
||||||
retryOnAuthFailure = true,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const session = getStoredSession()
|
|
||||||
const requestHeaders = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body !== undefined) {
|
|
||||||
requestHeaders['Content-Type'] = 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skipAuth && session?.accessToken) {
|
|
||||||
requestHeaders.Authorization = `Bearer ${session.accessToken}`
|
|
||||||
}
|
|
||||||
|
|
||||||
let response
|
|
||||||
|
|
||||||
try {
|
|
||||||
response = await fetch(buildUrl(path), {
|
|
||||||
method,
|
|
||||||
headers: requestHeaders,
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
throw new ApiError(
|
|
||||||
'Unable to reach the API. Make sure the backend is running and the certificate is trusted.',
|
|
||||||
0,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await readResponse(response)
|
|
||||||
|
|
||||||
if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) {
|
|
||||||
await refreshSession()
|
|
||||||
return requestJson(path, { ...options, retryOnAuthFailure: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ApiError(
|
|
||||||
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
|
|
||||||
response.status,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authApi = {
|
|
||||||
async register(payload) {
|
|
||||||
return requestJson('/api/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
skipAuth: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async login(payload) {
|
|
||||||
const data = await requestJson('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
skipAuth: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
saveSession({
|
|
||||||
accessToken: data.accessToken,
|
|
||||||
refreshToken: data.refreshToken,
|
|
||||||
user: data.user ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
try {
|
|
||||||
await requestJson('/api/auth/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
clearSession()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const profileApi = {
|
|
||||||
getProfile() {
|
|
||||||
return requestJson('/api/profile')
|
|
||||||
},
|
|
||||||
|
|
||||||
getProtectedData() {
|
|
||||||
return requestJson('/api/profile/data')
|
|
||||||
},
|
|
||||||
|
|
||||||
updateProfile(payload) {
|
|
||||||
return requestJson('/api/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const locationsApi = {
|
|
||||||
getLocations() {
|
|
||||||
return requestJson('/api/locations')
|
|
||||||
},
|
|
||||||
|
|
||||||
getLocationHistory(id) {
|
|
||||||
return requestJson(`/api/locations/${id}/history`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createLocation(payload) {
|
|
||||||
return requestJson('/api/locations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateLocation(id, payload) {
|
|
||||||
return requestJson(`/api/locations/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteLocation(id) {
|
|
||||||
return requestJson(`/api/locations/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const inventoryApi = {
|
|
||||||
getInventoryItems() {
|
|
||||||
return requestJson('/api/inventoryitems')
|
|
||||||
},
|
|
||||||
|
|
||||||
getInventoryItem(id) {
|
|
||||||
return requestJson(`/api/inventoryitems/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createInventoryItem(payload) {
|
|
||||||
return requestJson('/api/inventoryitems', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateInventoryItem(id, payload) {
|
|
||||||
return requestJson(`/api/inventoryitems/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteInventoryItem(id) {
|
|
||||||
return requestJson(`/api/inventoryitems/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchApi = {
|
|
||||||
searchLocations(query) {
|
|
||||||
return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
searchItems(query) {
|
|
||||||
return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const householdsApi = {
|
|
||||||
getHouseholds() {
|
|
||||||
return requestJson('/api/households')
|
|
||||||
},
|
|
||||||
|
|
||||||
getHouseholdHistory(id) {
|
|
||||||
return requestJson(`/api/households/${id}/history`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createHousehold(payload) {
|
|
||||||
return requestJson('/api/households', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateHousehold(id, payload) {
|
|
||||||
return requestJson(`/api/households/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
inviteHouseholdMember(id, payload) {
|
|
||||||
return requestJson(`/api/households/${id}/invite`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
leaveHousehold(id) {
|
|
||||||
return requestJson(`/api/households/${id}/leave`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usersApi = {
|
|
||||||
getUsers() {
|
|
||||||
return requestJson('/api/users')
|
|
||||||
},
|
|
||||||
|
|
||||||
getUser(id) {
|
|
||||||
return requestJson(`/api/users/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUser(id, payload) {
|
|
||||||
return requestJson(`/api/users/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const shoppingListsApi = {
|
|
||||||
getShoppingLists() {
|
|
||||||
return requestJson('/api/shoppinglists')
|
|
||||||
},
|
|
||||||
|
|
||||||
getShoppingList(id) {
|
|
||||||
return requestJson(`/api/shoppinglists/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createShoppingList(payload) {
|
|
||||||
return requestJson('/api/shoppinglists', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateShoppingList(id, payload) {
|
|
||||||
return requestJson(`/api/shoppinglists/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteShoppingList(id) {
|
|
||||||
return requestJson(`/api/shoppinglists/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mealPlannersApi = {
|
|
||||||
getMealPlanners() {
|
|
||||||
return requestJson('/api/mealplanners')
|
|
||||||
},
|
|
||||||
|
|
||||||
getMealPlanner(id) {
|
|
||||||
return requestJson(`/api/mealplanners/${id}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
createMealPlanner(payload) {
|
|
||||||
return requestJson('/api/mealplanners', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
updateMealPlanner(id, payload) {
|
|
||||||
return requestJson(`/api/mealplanners/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteMealPlanner(id) {
|
|
||||||
return requestJson(`/api/mealplanners/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
531
src/api/client.ts
Normal file
531
src/api/client.ts
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
import type {
|
||||||
|
AuthCredentials,
|
||||||
|
AuthResponse,
|
||||||
|
AuthSession,
|
||||||
|
EntityId,
|
||||||
|
Household,
|
||||||
|
HouseholdHistoryEntry,
|
||||||
|
HouseholdInvitePayload,
|
||||||
|
HouseholdPayload,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemPayload,
|
||||||
|
Location,
|
||||||
|
LocationHistoryEntry,
|
||||||
|
LocationPayload,
|
||||||
|
MealPlanner,
|
||||||
|
MealPlannerPayload,
|
||||||
|
ProfileUpdatePayload,
|
||||||
|
RegisterPayload,
|
||||||
|
ShoppingList,
|
||||||
|
ShoppingListPayload,
|
||||||
|
User,
|
||||||
|
} from '../types/models'
|
||||||
|
|
||||||
|
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: Promise<AuthSession> | null = null
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
data: unknown
|
||||||
|
|
||||||
|
constructor(message: string, status = 0, data: unknown = null) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: string
|
||||||
|
body?: unknown
|
||||||
|
headers?: Record<string, string>
|
||||||
|
skipAuth?: boolean
|
||||||
|
retryOnAuthFailure?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string): string {
|
||||||
|
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredSession(rawValue: string | null): AuthSession | null {
|
||||||
|
if (!rawValue) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue) as AuthSession
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchSessionChange(session: AuthSession | null): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
|
||||||
|
detail: session,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredSession(): AuthSession | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
|
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(session: AuthSession): AuthSession {
|
||||||
|
if (typeof window === 'undefined') return session
|
||||||
|
|
||||||
|
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||||
|
dispatchSessionChange(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||||
|
dispatchSessionChange(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToSessionChanges(listener: (session: AuthSession | null) => void): () => void {
|
||||||
|
if (typeof window === 'undefined') return () => {}
|
||||||
|
|
||||||
|
function handleCustomEvent(event: Event) {
|
||||||
|
listener((event as CustomEvent<AuthSession | null>).detail ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStorageEvent(event: StorageEvent) {
|
||||||
|
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: Response): Promise<unknown> {
|
||||||
|
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: unknown, fallbackMessage: string): string {
|
||||||
|
if (!data) return fallbackMessage
|
||||||
|
|
||||||
|
if (typeof data === 'string' && data.trim()) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
return fallbackMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
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.map(String).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.title === 'string' && data.title.trim()) {
|
||||||
|
return data.title
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh(session: AuthSession | null): Promise<AuthSession> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = data as Partial<AuthResponse>
|
||||||
|
|
||||||
|
return saveSession({
|
||||||
|
accessToken: refreshData.accessToken ?? '',
|
||||||
|
refreshToken: refreshData.refreshToken ?? '',
|
||||||
|
user: refreshData.user ?? session.user ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession(): Promise<AuthSession> {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh(getStoredSession())
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const {
|
||||||
|
method = 'GET',
|
||||||
|
body,
|
||||||
|
headers = {},
|
||||||
|
skipAuth = false,
|
||||||
|
retryOnAuthFailure = true,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const session = getStoredSession()
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
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<T>(path, { ...options, retryOnAuthFailure: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
|
||||||
|
response.status,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
async register(payload: RegisterPayload): Promise<AuthResponse> {
|
||||||
|
return requestJson<AuthResponse>('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(payload: AuthCredentials): Promise<AuthResponse> {
|
||||||
|
const data = await requestJson<AuthResponse>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
skipAuth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
saveSession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
user: data.user ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await requestJson('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const profileApi = {
|
||||||
|
getProfile(): Promise<User> {
|
||||||
|
return requestJson<User>('/api/profile')
|
||||||
|
},
|
||||||
|
|
||||||
|
getProtectedData(): Promise<unknown> {
|
||||||
|
return requestJson<unknown>('/api/profile/data')
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile(payload: ProfileUpdatePayload): Promise<User> {
|
||||||
|
return requestJson<User>('/api/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const locationsApi = {
|
||||||
|
getLocations(): Promise<Location[]> {
|
||||||
|
return requestJson<Location[]>('/api/locations')
|
||||||
|
},
|
||||||
|
|
||||||
|
getLocationHistory(id: EntityId): Promise<LocationHistoryEntry[]> {
|
||||||
|
return requestJson<LocationHistoryEntry[]>(`/api/locations/${id}/history`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createLocation(payload: LocationPayload): Promise<Location> {
|
||||||
|
return requestJson<Location>('/api/locations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLocation(id: EntityId, payload: LocationPayload): Promise<Location> {
|
||||||
|
return requestJson<Location>(`/api/locations/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteLocation(id: EntityId): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/locations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inventoryApi = {
|
||||||
|
getInventoryItems(): Promise<InventoryItem[]> {
|
||||||
|
return requestJson<InventoryItem[]>('/api/inventoryitems')
|
||||||
|
},
|
||||||
|
|
||||||
|
getInventoryItem(id: EntityId): Promise<InventoryItem> {
|
||||||
|
return requestJson<InventoryItem>(`/api/inventoryitems/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createInventoryItem(payload: InventoryItemPayload): Promise<InventoryItem> {
|
||||||
|
return requestJson<InventoryItem>('/api/inventoryitems', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateInventoryItem(id: EntityId, payload: InventoryItemPayload): Promise<InventoryItem> {
|
||||||
|
return requestJson<InventoryItem>(`/api/inventoryitems/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteInventoryItem(id: EntityId): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/inventoryitems/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchApi = {
|
||||||
|
searchLocations(query: string): Promise<Location[]> {
|
||||||
|
return requestJson<Location[]>(`/api/search/locations?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
searchItems(query: string): Promise<InventoryItem[]> {
|
||||||
|
return requestJson<InventoryItem[]>(`/api/search/items?q=${encodeURIComponent(query)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const householdsApi = {
|
||||||
|
getHouseholds(): Promise<Household[]> {
|
||||||
|
return requestJson<Household[]>('/api/households')
|
||||||
|
},
|
||||||
|
|
||||||
|
getHouseholdHistory(id: EntityId): Promise<HouseholdHistoryEntry[]> {
|
||||||
|
return requestJson<HouseholdHistoryEntry[]>(`/api/households/${id}/history`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createHousehold(payload: HouseholdPayload): Promise<Household> {
|
||||||
|
return requestJson<Household>('/api/households', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHousehold(id: EntityId, payload: HouseholdPayload): Promise<Household> {
|
||||||
|
return requestJson<Household>(`/api/households/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
inviteHouseholdMember(id: EntityId, payload: HouseholdInvitePayload): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/households/${id}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveHousehold(id: EntityId): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/households/${id}/leave`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
getUsers(): Promise<User[]> {
|
||||||
|
return requestJson<User[]>('/api/users')
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser(id: EntityId): Promise<User> {
|
||||||
|
return requestJson<User>(`/api/users/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser(id: EntityId, payload: Partial<RegisterPayload> & { roles?: string[] }): Promise<User> {
|
||||||
|
return requestJson<User>(`/api/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shoppingListsApi = {
|
||||||
|
getShoppingLists(): Promise<ShoppingList[]> {
|
||||||
|
return requestJson<ShoppingList[]>('/api/shoppinglists')
|
||||||
|
},
|
||||||
|
|
||||||
|
getShoppingList(id: EntityId): Promise<ShoppingList> {
|
||||||
|
return requestJson<ShoppingList>(`/api/shoppinglists/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createShoppingList(payload: ShoppingListPayload): Promise<ShoppingList> {
|
||||||
|
return requestJson<ShoppingList>('/api/shoppinglists', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateShoppingList(id: EntityId, payload: ShoppingListPayload): Promise<ShoppingList> {
|
||||||
|
return requestJson<ShoppingList>(`/api/shoppinglists/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteShoppingList(id: EntityId): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/shoppinglists/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mealPlannersApi = {
|
||||||
|
getMealPlanners(): Promise<MealPlanner[]> {
|
||||||
|
return requestJson<MealPlanner[]>('/api/mealplanners')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMealPlanner(id: EntityId): Promise<MealPlanner> {
|
||||||
|
return requestJson<MealPlanner>(`/api/mealplanners/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createMealPlanner(payload: MealPlannerPayload): Promise<MealPlanner> {
|
||||||
|
return requestJson<MealPlanner>('/api/mealplanners', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMealPlanner(id: EntityId, payload: MealPlannerPayload): Promise<MealPlanner> {
|
||||||
|
return requestJson<MealPlanner>(`/api/mealplanners/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMealPlanner(id: EntityId): Promise<void> {
|
||||||
|
return requestJson<void>(`/api/mealplanners/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,29 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import './ItemCard.css'
|
import './ItemCard.css'
|
||||||
|
|
||||||
function ItemCard({ imgSrc = '', imgAlt = '', text1 = '', text2 = '', text3 = '', editable = false, imgWidth = 80, imgHeight = 80, children }) {
|
interface ItemCardProps {
|
||||||
|
imgSrc?: string
|
||||||
|
imgAlt?: string
|
||||||
|
text1?: string
|
||||||
|
text2?: string
|
||||||
|
text3?: string
|
||||||
|
editable?: boolean
|
||||||
|
imgWidth?: number
|
||||||
|
imgHeight?: number
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemCard({
|
||||||
|
imgSrc = '',
|
||||||
|
imgAlt = '',
|
||||||
|
text1 = '',
|
||||||
|
text2 = '',
|
||||||
|
text3 = '',
|
||||||
|
editable = false,
|
||||||
|
imgWidth = 80,
|
||||||
|
imgHeight = 80,
|
||||||
|
children,
|
||||||
|
}: ItemCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="item-component">
|
<div className="item-component">
|
||||||
<div className="item-component__img">
|
<div className="item-component__img">
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { Theme } from '../../App'
|
||||||
|
import type { User } from '../../types/models'
|
||||||
import './Navbar.css'
|
import './Navbar.css'
|
||||||
|
|
||||||
const MOBILE_NAV_BREAKPOINT = 760
|
const MOBILE_NAV_BREAKPOINT = 760
|
||||||
|
|
||||||
function formatUserDisplayName(user) {
|
interface NavbarProps {
|
||||||
|
theme: Theme
|
||||||
|
onToggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserDisplayName(user: User | null): string {
|
||||||
const fullName = [user?.firstName, user?.lastName]
|
const fullName = [user?.firstName, user?.lastName]
|
||||||
.filter(part => typeof part === 'string' && part.trim())
|
.filter(part => typeof part === 'string' && part.trim())
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -30,7 +37,7 @@ function MoonIcon() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Navbar({ theme, onToggleTheme }) {
|
function Navbar({ theme, onToggleTheme }: NavbarProps) {
|
||||||
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
|
const { isAuthenticated, isSiteAdmin, logout, user } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
@@ -115,7 +122,7 @@ function Navbar({ theme, onToggleTheme }) {
|
|||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<button type="button" className="nav-button" onClick={() => {
|
<button type="button" className="nav-button" onClick={() => {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
logout()
|
void logout()
|
||||||
}}>
|
}}>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
@@ -4,7 +4,22 @@ import './PieChart.css'
|
|||||||
const RADIUS = 10
|
const RADIUS = 10
|
||||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||||
|
|
||||||
function PieChart({ segments }) {
|
export interface PieSegment {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComputedPieSegment extends PieSegment {
|
||||||
|
segmentLength: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieChartProps {
|
||||||
|
segments: PieSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function PieChart({ segments }: PieChartProps) {
|
||||||
const computedSegments = useMemo(() => {
|
const computedSegments = useMemo(() => {
|
||||||
const total = segments.reduce((acc, seg) => acc + seg.value, 0)
|
const total = segments.reduce((acc, seg) => acc + seg.value, 0)
|
||||||
let currentOffset = 0
|
let currentOffset = 0
|
||||||
@@ -15,7 +30,7 @@ function PieChart({ segments }) {
|
|||||||
currentOffset += segmentLength
|
currentOffset += segmentLength
|
||||||
return { ...seg, segmentLength, offset }
|
return { ...seg, segmentLength, offset }
|
||||||
})
|
})
|
||||||
}, [segments])
|
}, [segments]) satisfies ComputedPieSegment[]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
authApi,
|
authApi,
|
||||||
getStoredSession,
|
getStoredSession,
|
||||||
profileApi,
|
profileApi,
|
||||||
saveSession,
|
saveSession,
|
||||||
subscribeToSessionChanges,
|
subscribeToSessionChanges,
|
||||||
} from '../api/client.js'
|
} from '../api/client'
|
||||||
|
import type { AuthCredentials, AuthResponse, AuthSession, RegisterPayload, User } from '../types/models'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
interface AuthContextValue {
|
||||||
|
session: AuthSession | null
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isSiteAdmin: boolean
|
||||||
|
initializing: boolean
|
||||||
|
login: (payload: AuthCredentials) => Promise<AuthResponse>
|
||||||
|
register: (payload: RegisterPayload) => Promise<AuthResponse>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshProfile: () => Promise<User | null>
|
||||||
|
setCurrentUserProfile: (profile: User) => AuthSession | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin'])
|
const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin'])
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
const [session, setSession] = useState(() => getStoredSession())
|
const [session, setSession] = useState(() => getStoredSession())
|
||||||
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
|
const [initializing, setInitializing] = useState(() => Boolean(getStoredSession()))
|
||||||
|
|
||||||
function setCurrentUserProfile(profile) {
|
function setCurrentUserProfile(profile: User): AuthSession | null {
|
||||||
const currentSession = getStoredSession()
|
const currentSession = getStoredSession()
|
||||||
|
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
@@ -27,7 +45,7 @@ export function AuthProvider({ children }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshProfile() {
|
async function refreshProfile(): Promise<User | null> {
|
||||||
const currentSession = getStoredSession()
|
const currentSession = getStoredSession()
|
||||||
|
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
function getDate(daysFromNow) {
|
import type { DemoInventoryItem } from '../types/models'
|
||||||
|
|
||||||
|
function getDate(daysFromNow: number): string {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
date.setDate(date.getDate() + daysFromNow)
|
date.setDate(date.getDate() + daysFromNow)
|
||||||
return date.toISOString().split('T')[0]
|
return date.toISOString().split('T')[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inventoryData = [
|
export const inventoryData: DemoInventoryItem[] = [
|
||||||
// Pantry items
|
// Pantry items
|
||||||
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
|
{ id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' },
|
||||||
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },
|
{ id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' },
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App'
|
||||||
import { AuthProvider } from './context/AuthContext.jsx'
|
import { AuthProvider } from './context/AuthContext'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
const rootElement = document.getElementById('root')
|
||||||
|
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found.')
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { householdsApi, usersApi } from '../../api/client.js'
|
import { householdsApi, usersApi } from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { formatDate } from '../../utils/searchUtils.js'
|
import { formatDate } from '../../utils/searchUtils'
|
||||||
|
import type { Household, HouseholdHistoryEntry, HouseholdMember, User } from '../../types/models'
|
||||||
import './AdminPage.css'
|
import './AdminPage.css'
|
||||||
|
|
||||||
const EMPTY_HOUSEHOLD_FORM = {
|
interface HouseholdForm {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCreateForm {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_HOUSEHOLD_FORM: HouseholdForm = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_USER_FORM = {
|
const EMPTY_USER_FORM: UserCreateForm = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -17,7 +31,7 @@ const EMPTY_USER_FORM = {
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPersonName(person) {
|
function formatPersonName(person: User | HouseholdMember): string {
|
||||||
const fullName = [person.firstName, person.lastName]
|
const fullName = [person.firstName, person.lastName]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -30,18 +44,18 @@ function AdminPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [households, setHouseholds] = useState([])
|
const [households, setHouseholds] = useState<Household[]>([])
|
||||||
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
|
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
|
||||||
const [editingHouseholdId, setEditingHouseholdId] = useState('')
|
const [editingHouseholdId, setEditingHouseholdId] = useState('')
|
||||||
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
|
const [householdForm, setHouseholdForm] = useState<HouseholdForm>(EMPTY_HOUSEHOLD_FORM)
|
||||||
const [inviteEmail, setInviteEmail] = useState('')
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
const [userForm, setUserForm] = useState<UserCreateForm>(EMPTY_USER_FORM)
|
||||||
const [createdUser, setCreatedUser] = useState(null)
|
const [createdUser, setCreatedUser] = useState<User | null>(null)
|
||||||
const [householdHistory, setHouseholdHistory] = useState([])
|
const [householdHistory, setHouseholdHistory] = useState<HouseholdHistoryEntry[]>([])
|
||||||
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
|
const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false)
|
||||||
const [householdHistoryError, setHouseholdHistoryError] = useState('')
|
const [householdHistoryError, setHouseholdHistoryError] = useState('')
|
||||||
|
|
||||||
async function loadHouseholds(preferredSelectionId = '') {
|
async function loadHouseholds(preferredSelectionId = ''): Promise<void> {
|
||||||
const response = await householdsApi.getHouseholds()
|
const response = await householdsApi.getHouseholds()
|
||||||
const nextHouseholds = Array.isArray(response) ? response : []
|
const nextHouseholds = Array.isArray(response) ? response : []
|
||||||
|
|
||||||
@@ -167,7 +181,7 @@ function AdminPage() {
|
|||||||
setUserForm(EMPTY_USER_FORM)
|
setUserForm(EMPTY_USER_FORM)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHouseholdSubmit(event) {
|
async function handleHouseholdSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = householdForm.name.trim()
|
const name = householdForm.name.trim()
|
||||||
@@ -208,7 +222,7 @@ function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleInviteSubmit(event) {
|
async function handleInviteSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const email = inviteEmail.trim()
|
const email = inviteEmail.trim()
|
||||||
@@ -244,7 +258,7 @@ function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserSubmit(event) {
|
async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const email = userForm.email.trim()
|
const email = userForm.email.trim()
|
||||||
@@ -289,6 +303,7 @@ function AdminPage() {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
setCreatedUser(matchingUser ?? {
|
setCreatedUser(matchingUser ?? {
|
||||||
|
id: '',
|
||||||
email,
|
email,
|
||||||
firstName: userForm.firstName.trim(),
|
firstName: userForm.firstName.trim(),
|
||||||
lastName: userForm.lastName.trim(),
|
lastName: userForm.lastName.trim(),
|
||||||
@@ -296,6 +311,7 @@ function AdminPage() {
|
|||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setCreatedUser({
|
setCreatedUser({
|
||||||
|
id: '',
|
||||||
email,
|
email,
|
||||||
firstName: userForm.firstName.trim(),
|
firstName: userForm.firstName.trim(),
|
||||||
lastName: userForm.lastName.trim(),
|
lastName: userForm.lastName.trim(),
|
||||||
@@ -312,7 +328,7 @@ function AdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLeaveHousehold(householdId) {
|
async function handleLeaveHousehold(householdId: string) {
|
||||||
const confirmed = window.confirm('Leave this household?')
|
const confirmed = window.confirm('Leave this household?')
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -405,7 +421,7 @@ function AdminPage() {
|
|||||||
<label htmlFor="household-description">Description</label>
|
<label htmlFor="household-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="household-description"
|
id="household-description"
|
||||||
rows="3"
|
rows={3}
|
||||||
value={householdForm.description}
|
value={householdForm.description}
|
||||||
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
|
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
|
||||||
placeholder="Shared pantry and fridge access"
|
placeholder="Shared pantry and fridge access"
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, type FormEvent } from 'react'
|
||||||
import Quagga from '@ericblade/quagga2'
|
import Quagga from '@ericblade/quagga2'
|
||||||
|
import type { QuaggaJSCodeReader, QuaggaJSResultCallbackFunction } from '@ericblade/quagga2'
|
||||||
import {
|
import {
|
||||||
inventoryApi,
|
inventoryApi,
|
||||||
locationsApi,
|
locationsApi,
|
||||||
} from '../../api/client.js'
|
} from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
buildInventoryPayload,
|
buildInventoryPayload,
|
||||||
createItemForm,
|
createItemForm,
|
||||||
findItemsByBarcode,
|
findItemsByBarcode,
|
||||||
mapItemToForm,
|
mapItemToForm,
|
||||||
normalizeBarcode,
|
normalizeBarcode,
|
||||||
} from '../../utils/inventoryItemUtils.js'
|
} from '../../utils/inventoryItemUtils'
|
||||||
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
import { formatAmount, formatDate } from '../../utils/searchUtils'
|
||||||
|
import type { InventoryItem, InventoryItemForm, Location } from '../../types/models'
|
||||||
import './BarcodePage.css'
|
import './BarcodePage.css'
|
||||||
|
|
||||||
const MATCH_CANDIDATE_LIMIT = 10
|
const MATCH_CANDIDATE_LIMIT = 10
|
||||||
const CAMERA_READERS = [
|
const CAMERA_READERS: QuaggaJSCodeReader[] = [
|
||||||
'ean_reader',
|
'ean_reader',
|
||||||
'ean_8_reader',
|
'ean_8_reader',
|
||||||
'upc_reader',
|
'upc_reader',
|
||||||
@@ -25,14 +27,23 @@ const CAMERA_READERS = [
|
|||||||
'code_39_reader',
|
'code_39_reader',
|
||||||
]
|
]
|
||||||
|
|
||||||
function getScanSourceLabel(source) {
|
type ScanMode = 'camera' | 'keyboard'
|
||||||
|
type ScanSource = '' | 'camera' | 'hardware-scanner' | 'keyboard-paste' | 'manual'
|
||||||
|
type EditorMode = '' | 'create' | 'update'
|
||||||
|
|
||||||
|
interface InventoryData {
|
||||||
|
nextLocations: Location[]
|
||||||
|
nextItems: InventoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScanSourceLabel(source: ScanSource): string {
|
||||||
if (source === 'camera') return 'Camera'
|
if (source === 'camera') return 'Camera'
|
||||||
if (source === 'hardware-scanner') return 'Scanner'
|
if (source === 'hardware-scanner') return 'Scanner'
|
||||||
if (source === 'keyboard-paste') return 'Paste'
|
if (source === 'keyboard-paste') return 'Paste'
|
||||||
return 'Manual'
|
return 'Manual'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMatchCandidates(items, query, scannedBarcode) {
|
function getMatchCandidates(items: InventoryItem[], query: string, scannedBarcode: string): InventoryItem[] {
|
||||||
const normalizedQuery = query.trim().toLowerCase()
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
const normalizedBarcode = normalizeBarcode(scannedBarcode)
|
const normalizedBarcode = normalizeBarcode(scannedBarcode)
|
||||||
|
|
||||||
@@ -57,25 +68,25 @@ function getMatchCandidates(items, query, scannedBarcode) {
|
|||||||
|
|
||||||
function BarcodePage() {
|
function BarcodePage() {
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const [mode, setMode] = useState('camera')
|
const [mode, setMode] = useState<ScanMode>('camera')
|
||||||
const [cameraActive, setCameraActive] = useState(false)
|
const [cameraActive, setCameraActive] = useState(false)
|
||||||
const [manualBarcodeInput, setManualBarcodeInput] = useState('')
|
const [manualBarcodeInput, setManualBarcodeInput] = useState('')
|
||||||
const [inventoryLoading, setInventoryLoading] = useState(false)
|
const [inventoryLoading, setInventoryLoading] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [lastScannedBarcode, setLastScannedBarcode] = useState('')
|
const [lastScannedBarcode, setLastScannedBarcode] = useState('')
|
||||||
const [lastScanSource, setLastScanSource] = useState('')
|
const [lastScanSource, setLastScanSource] = useState<ScanSource>('')
|
||||||
const [locations, setLocations] = useState([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState<InventoryItem[]>([])
|
||||||
const [editorMode, setEditorMode] = useState('')
|
const [editorMode, setEditorMode] = useState<EditorMode>('')
|
||||||
const [editingItemId, setEditingItemId] = useState('')
|
const [editingItemId, setEditingItemId] = useState('')
|
||||||
const [itemForm, setItemForm] = useState(createItemForm())
|
const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
|
||||||
const [matchQuery, setMatchQuery] = useState('')
|
const [matchQuery, setMatchQuery] = useState('')
|
||||||
|
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const scannerTargetRef = useRef(null)
|
const scannerTargetRef = useRef<HTMLDivElement | null>(null)
|
||||||
const detectedHandlerRef = useRef(null)
|
const detectedHandlerRef = useRef<QuaggaJSResultCallbackFunction | null>(null)
|
||||||
const lastCameraScanRef = useRef({ barcode: '', at: 0 })
|
const lastCameraScanRef = useRef({ barcode: '', at: 0 })
|
||||||
|
|
||||||
const matchingItems = findItemsByBarcode(items, lastScannedBarcode)
|
const matchingItems = findItemsByBarcode(items, lastScannedBarcode)
|
||||||
@@ -84,7 +95,7 @@ function BarcodePage() {
|
|||||||
const hasExactMatches = matchingItems.length > 0
|
const hasExactMatches = matchingItems.length > 0
|
||||||
const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
|
const quickAddSourceItem = matchingItems.find(item => item.id === editingItemId) ?? matchingItems[0] ?? null
|
||||||
|
|
||||||
const fetchInventoryData = useCallback(async () => {
|
const fetchInventoryData = useCallback(async (): Promise<InventoryData> => {
|
||||||
const [nextLocations, nextItems] = await Promise.all([
|
const [nextLocations, nextItems] = await Promise.all([
|
||||||
locationsApi.getLocations(),
|
locationsApi.getLocations(),
|
||||||
inventoryApi.getInventoryItems(),
|
inventoryApi.getInventoryItems(),
|
||||||
@@ -93,7 +104,7 @@ function BarcodePage() {
|
|||||||
return { nextLocations, nextItems }
|
return { nextLocations, nextItems }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyInventoryData = useCallback(({ nextLocations, nextItems }) => {
|
const applyInventoryData = useCallback(({ nextLocations, nextItems }: InventoryData) => {
|
||||||
setLocations(nextLocations)
|
setLocations(nextLocations)
|
||||||
setItems(nextItems)
|
setItems(nextItems)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -126,7 +137,7 @@ function BarcodePage() {
|
|||||||
setCameraActive(false)
|
setCameraActive(false)
|
||||||
}, [clearScannerTarget])
|
}, [clearScannerTarget])
|
||||||
|
|
||||||
const onBarcodeScanned = useCallback((barcode, inputType) => {
|
const onBarcodeScanned = useCallback((barcode: string | null | undefined, inputType: ScanSource) => {
|
||||||
const normalizedBarcode = normalizeBarcode(barcode)
|
const normalizedBarcode = normalizeBarcode(barcode)
|
||||||
|
|
||||||
if (!normalizedBarcode) {
|
if (!normalizedBarcode) {
|
||||||
@@ -206,15 +217,17 @@ function BarcodePage() {
|
|||||||
throw new Error('The camera preview is not ready yet. Try again.')
|
throw new Error('The camera preview is not ready yet. Try again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scannerTarget = scannerTargetRef.current
|
||||||
|
|
||||||
stopCamera()
|
stopCamera()
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
Quagga.init({
|
Quagga.init({
|
||||||
inputStream: {
|
inputStream: {
|
||||||
type: 'LiveStream',
|
type: 'LiveStream',
|
||||||
target: scannerTargetRef.current,
|
target: scannerTarget,
|
||||||
constraints: {
|
constraints: {
|
||||||
facingMode: { ideal: 'environment' },
|
facingMode: { ideal: 'environment' },
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
@@ -242,7 +255,7 @@ function BarcodePage() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const detectedHandler = result => {
|
const detectedHandler: QuaggaJSResultCallbackFunction = result => {
|
||||||
const barcode = result?.codeResult?.code
|
const barcode = result?.codeResult?.code
|
||||||
|
|
||||||
if (!barcode) {
|
if (!barcode) {
|
||||||
@@ -344,6 +357,26 @@ function BarcodePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleQuickRemove() {
|
||||||
|
if (!hasScannedBarcode || !quickAddSourceItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
setStatusMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await inventoryApi.deleteInventoryItem(quickAddSourceItem.id)
|
||||||
|
await refreshInventoryData()
|
||||||
|
setStatusMessage(`Quick removed ${quickAddSourceItem.name || 'item'}.`)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRefreshInventory() {
|
async function handleRefreshInventory() {
|
||||||
setInventoryLoading(true)
|
setInventoryLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
@@ -358,7 +391,7 @@ function BarcodePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditItem(itemId) {
|
async function handleEditItem(itemId: string) {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
@@ -375,7 +408,7 @@ function BarcodePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleItemSubmit(event) {
|
async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = itemForm.name.trim()
|
const name = itemForm.name.trim()
|
||||||
@@ -424,7 +457,7 @@ function BarcodePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMatchItem(itemId, itemName, currentBarcode) {
|
async function handleMatchItem(itemId: string, itemName: string, currentBarcode?: string | null) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
currentBarcode
|
currentBarcode
|
||||||
? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?`
|
? `Replace the current barcode on ${itemName} with ${lastScannedBarcode}?`
|
||||||
@@ -521,6 +554,24 @@ function BarcodePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
disabled={!hasScannedBarcode || submitting || inventoryLoading}
|
||||||
|
>
|
||||||
|
Quick Add
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleQuickRemove}
|
||||||
|
disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading}
|
||||||
|
>
|
||||||
|
Quick Remove
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -574,6 +625,22 @@ function BarcodePage() {
|
|||||||
>
|
>
|
||||||
Check barcode
|
Check barcode
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleQuickAdd}
|
||||||
|
disabled={!hasScannedBarcode || submitting || inventoryLoading}
|
||||||
|
>
|
||||||
|
Quick Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleQuickRemove}
|
||||||
|
disabled={!hasScannedBarcode || !hasExactMatches || submitting || inventoryLoading}
|
||||||
|
>
|
||||||
|
Quick Remove
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -592,16 +659,6 @@ function BarcodePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="quick-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleQuickAdd}
|
|
||||||
disabled={!hasScannedBarcode || submitting || inventoryLoading}
|
|
||||||
>
|
|
||||||
Quick Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* <section className="panel scan-summary-panel">
|
{/* <section className="panel scan-summary-panel">
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import PieChart from '../../components/PieChart/PieChart.jsx'
|
import PieChart from '../../components/PieChart/PieChart'
|
||||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
import ItemCard from '../../components/ItemCard/ItemCard'
|
||||||
import { inventoryApi, locationsApi } from '../../api/client.js'
|
import { inventoryApi, locationsApi } from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils.js'
|
import { formatAmount, formatDate, getExpiryStatus } from '../../utils/searchUtils'
|
||||||
|
import type { PieSegment } from '../../components/PieChart/PieChart'
|
||||||
|
import type { InventoryItem, Location } from '../../types/models'
|
||||||
import './HomePage.css'
|
import './HomePage.css'
|
||||||
|
|
||||||
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
|
const PIE_COLORS = ['#2563eb', '#0f766e', '#9333ea', '#ea580c', '#dc2626', '#0891b2']
|
||||||
@@ -13,7 +15,7 @@ const INITIAL_LOGIN_FORM = {
|
|||||||
password: '',
|
password: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPieSegments(items, locations) {
|
function buildPieSegments(items: InventoryItem[], locations: Location[]): PieSegment[] {
|
||||||
const counts = new Map(locations.map(location => [location.name, 0]))
|
const counts = new Map(locations.map(location => [location.name, 0]))
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@@ -30,7 +32,7 @@ function buildPieSegments(items, locations) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByExpiry(items) {
|
function sortByExpiry(items: InventoryItem[]): InventoryItem[] {
|
||||||
return [...items].sort((left, right) => {
|
return [...items].sort((left, right) => {
|
||||||
const leftDate = left.expiryDate ? new Date(left.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
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
|
const rightDate = right.expiryDate ? new Date(right.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||||
@@ -45,8 +47,8 @@ function HomePage() {
|
|||||||
const [formStatus, setFormStatus] = useState('')
|
const [formStatus, setFormStatus] = useState('')
|
||||||
const [dashboardError, setDashboardError] = useState('')
|
const [dashboardError, setDashboardError] = useState('')
|
||||||
const [dashboardLoading, setDashboardLoading] = useState(false)
|
const [dashboardLoading, setDashboardLoading] = useState(false)
|
||||||
const [locations, setLocations] = useState([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState<InventoryItem[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -90,7 +92,7 @@ function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
async function handleLoginSubmit(event) {
|
async function handleLoginSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setFormError('')
|
setFormError('')
|
||||||
setFormStatus('')
|
setFormStatus('')
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
inventoryApi,
|
inventoryApi,
|
||||||
locationsApi,
|
locationsApi,
|
||||||
} from '../../api/client.js'
|
} from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
buildInventoryPayload,
|
buildInventoryPayload,
|
||||||
createItemForm,
|
createItemForm,
|
||||||
mapItemToForm,
|
mapItemToForm,
|
||||||
} from '../../utils/inventoryItemUtils.js'
|
} from '../../utils/inventoryItemUtils'
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatDate,
|
formatDate,
|
||||||
getExpiryStatus,
|
getExpiryStatus,
|
||||||
} from '../../utils/searchUtils.js'
|
} from '../../utils/searchUtils'
|
||||||
|
import type {
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemForm,
|
||||||
|
Location,
|
||||||
|
LocationHistoryEntry,
|
||||||
|
} from '../../types/models'
|
||||||
import './InventoryPage.css'
|
import './InventoryPage.css'
|
||||||
|
|
||||||
const EMPTY_LOCATION_FORM = {
|
interface LocationForm {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_LOCATION_FORM: LocationForm = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
}
|
}
|
||||||
@@ -26,19 +37,19 @@ function InventoryPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [locations, setLocations] = useState([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState<InventoryItem[]>([])
|
||||||
const [selectedLocationId, setSelectedLocationId] = useState('')
|
const [selectedLocationId, setSelectedLocationId] = useState('')
|
||||||
const [locationHistory, setLocationHistory] = useState([])
|
const [locationHistory, setLocationHistory] = useState<LocationHistoryEntry[]>([])
|
||||||
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
|
const [locationHistoryLoading, setLocationHistoryLoading] = useState(false)
|
||||||
const [locationHistoryError, setLocationHistoryError] = useState('')
|
const [locationHistoryError, setLocationHistoryError] = useState('')
|
||||||
const [editingLocationId, setEditingLocationId] = useState('')
|
const [editingLocationId, setEditingLocationId] = useState('')
|
||||||
const [editingItemId, setEditingItemId] = useState('')
|
const [editingItemId, setEditingItemId] = useState('')
|
||||||
const [selectedItem, setSelectedItem] = useState(null)
|
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null)
|
||||||
const [locationForm, setLocationForm] = useState(EMPTY_LOCATION_FORM)
|
const [locationForm, setLocationForm] = useState<LocationForm>(EMPTY_LOCATION_FORM)
|
||||||
const [itemForm, setItemForm] = useState(createItemForm())
|
const [itemForm, setItemForm] = useState<InventoryItemForm>(createItemForm())
|
||||||
|
|
||||||
function syncLocations(nextLocations) {
|
function syncLocations(nextLocations: Location[]) {
|
||||||
setLocations(nextLocations)
|
setLocations(nextLocations)
|
||||||
setSelectedLocationId(currentLocationId => {
|
setSelectedLocationId(currentLocationId => {
|
||||||
const targetLocationId = currentLocationId || editingLocationId
|
const targetLocationId = currentLocationId || editingLocationId
|
||||||
@@ -165,7 +176,7 @@ function InventoryPage() {
|
|||||||
setItemForm(createItemForm())
|
setItemForm(createItemForm())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLocationSubmit(event) {
|
async function handleLocationSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = locationForm.name.trim()
|
const name = locationForm.name.trim()
|
||||||
@@ -202,7 +213,7 @@ function InventoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteLocation(locationId) {
|
async function handleDeleteLocation(locationId: string) {
|
||||||
const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.')
|
const confirmed = window.confirm('Delete this location? Inventory items still linked to it may block deletion.')
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -233,7 +244,7 @@ function InventoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditItem(itemId) {
|
async function handleEditItem(itemId: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
@@ -250,7 +261,7 @@ function InventoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleItemSubmit(event) {
|
async function handleItemSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = itemForm.name.trim()
|
const name = itemForm.name.trim()
|
||||||
@@ -292,7 +303,7 @@ function InventoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteItem(itemId) {
|
async function handleDeleteItem(itemId: string) {
|
||||||
const confirmed = window.confirm('Delete this inventory item?')
|
const confirmed = window.confirm('Delete this inventory item?')
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -360,7 +371,7 @@ function InventoryPage() {
|
|||||||
<label htmlFor="location-description">Description</label>
|
<label htmlFor="location-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="location-description"
|
id="location-description"
|
||||||
rows="3"
|
rows={3}
|
||||||
value={locationForm.description}
|
value={locationForm.description}
|
||||||
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
|
onChange={event => setLocationForm(current => ({ ...current, description: event.target.value }))}
|
||||||
placeholder="Main kitchen shelf"
|
placeholder="Main kitchen shelf"
|
||||||
@@ -1,20 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
householdsApi,
|
householdsApi,
|
||||||
inventoryApi,
|
inventoryApi,
|
||||||
mealPlannersApi,
|
mealPlannersApi,
|
||||||
} from '../../api/client.js'
|
} from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatTime,
|
formatTime,
|
||||||
toDateInputValue,
|
toDateInputValue,
|
||||||
toTimeInputValue,
|
toTimeInputValue,
|
||||||
} from '../../utils/searchUtils.js'
|
} from '../../utils/searchUtils'
|
||||||
|
import type {
|
||||||
|
Household,
|
||||||
|
InventoryItem,
|
||||||
|
MealPlanner,
|
||||||
|
MealPlannerForm,
|
||||||
|
MealPlannerItemForm,
|
||||||
|
MealPlannerItemPayload,
|
||||||
|
} from '../../types/models'
|
||||||
import '../PlanningPage.css'
|
import '../PlanningPage.css'
|
||||||
|
|
||||||
function createMealPlannerItemForm() {
|
function createMealPlannerItemForm(): MealPlannerItemForm {
|
||||||
return {
|
return {
|
||||||
inventoryItemId: '',
|
inventoryItemId: '',
|
||||||
amountRequired: '',
|
amountRequired: '',
|
||||||
@@ -22,7 +30,7 @@ function createMealPlannerItemForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMealPlannerForm(householdId = '') {
|
function createMealPlannerForm(householdId = ''): MealPlannerForm {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
householdId,
|
householdId,
|
||||||
@@ -32,7 +40,7 @@ function createMealPlannerForm(householdId = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapMealPlannerToForm(mealPlanner) {
|
function mapMealPlannerToForm(mealPlanner: MealPlanner | null): MealPlannerForm {
|
||||||
return {
|
return {
|
||||||
name: mealPlanner?.name ?? '',
|
name: mealPlanner?.name ?? '',
|
||||||
householdId: mealPlanner?.householdId ?? '',
|
householdId: mealPlanner?.householdId ?? '',
|
||||||
@@ -48,16 +56,16 @@ function mapMealPlannerToForm(mealPlanner) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHouseholdName(households, householdId) {
|
function resolveHouseholdName(households: Household[], householdId: string): string {
|
||||||
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toApiTimeValue(timeValue) {
|
function toApiTimeValue(timeValue: string): string {
|
||||||
if (!timeValue) return ''
|
if (!timeValue) return ''
|
||||||
return timeValue.length === 5 ? `${timeValue}:00` : timeValue
|
return timeValue.length === 5 ? `${timeValue}:00` : timeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMealPlannerItems(items) {
|
function normalizeMealPlannerItems(items: MealPlannerItemForm[]): MealPlannerItemPayload[] {
|
||||||
const normalizedItems = items
|
const normalizedItems = items
|
||||||
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
|
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim())
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
@@ -97,12 +105,12 @@ function MealPlannersPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [households, setHouseholds] = useState([])
|
const [households, setHouseholds] = useState<Household[]>([])
|
||||||
const [inventoryItems, setInventoryItems] = useState([])
|
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
|
||||||
const [mealPlanners, setMealPlanners] = useState([])
|
const [mealPlanners, setMealPlanners] = useState<MealPlanner[]>([])
|
||||||
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
|
const [selectedMealPlannerId, setSelectedMealPlannerId] = useState('')
|
||||||
const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
|
const [editingMealPlannerId, setEditingMealPlannerId] = useState('')
|
||||||
const [mealPlannerForm, setMealPlannerForm] = useState(createMealPlannerForm())
|
const [mealPlannerForm, setMealPlannerForm] = useState<MealPlannerForm>(createMealPlannerForm())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -169,7 +177,7 @@ function MealPlannersPage() {
|
|||||||
}))
|
}))
|
||||||
}, [editingMealPlannerId, households, mealPlannerForm.householdId])
|
}, [editingMealPlannerId, households, mealPlannerForm.householdId])
|
||||||
|
|
||||||
async function loadPageData(preferredSelectionId = '') {
|
async function loadPageData(preferredSelectionId = ''): Promise<void> {
|
||||||
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
|
const [nextHouseholdsResponse, nextInventoryResponse, nextMealPlannersResponse] = await Promise.all([
|
||||||
householdsApi.getHouseholds(),
|
householdsApi.getHouseholds(),
|
||||||
inventoryApi.getInventoryItems(),
|
inventoryApi.getInventoryItems(),
|
||||||
@@ -204,7 +212,7 @@ function MealPlannersPage() {
|
|||||||
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
|
setMealPlannerForm(createMealPlannerForm(households[0]?.id ?? ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItemRow(index, updates) {
|
function updateItemRow(index: number, updates: Partial<MealPlannerItemForm>) {
|
||||||
setMealPlannerForm(currentForm => ({
|
setMealPlannerForm(currentForm => ({
|
||||||
...currentForm,
|
...currentForm,
|
||||||
items: currentForm.items.map((item, itemIndex) => (
|
items: currentForm.items.map((item, itemIndex) => (
|
||||||
@@ -222,14 +230,14 @@ function MealPlannersPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItemRow(index) {
|
function removeItemRow(index: number) {
|
||||||
setMealPlannerForm(currentForm => ({
|
setMealPlannerForm(currentForm => ({
|
||||||
...currentForm,
|
...currentForm,
|
||||||
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditMealPlanner(mealPlannerId) {
|
async function handleEditMealPlanner(mealPlannerId: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
@@ -246,7 +254,7 @@ function MealPlannersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMealPlannerSubmit(event) {
|
async function handleMealPlannerSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = mealPlannerForm.name.trim()
|
const name = mealPlannerForm.name.trim()
|
||||||
@@ -311,7 +319,7 @@ function MealPlannersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteMealPlanner(mealPlannerId) {
|
async function handleDeleteMealPlanner(mealPlannerId: string) {
|
||||||
const confirmed = window.confirm('Delete this meal planner?')
|
const confirmed = window.confirm('Delete this meal planner?')
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -543,7 +551,7 @@ function MealPlannersPage() {
|
|||||||
{(mealPlanner.items ?? []).length === 0 ? (
|
{(mealPlanner.items ?? []).length === 0 ? (
|
||||||
<div className="entity-meta">No items added yet.</div>
|
<div className="entity-meta">No items added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
mealPlanner.items.map(item => (
|
(mealPlanner.items ?? []).map(item => (
|
||||||
<div className="planning-summary-row" key={item.inventoryItemId}>
|
<div className="planning-summary-row" key={item.inventoryItemId}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { profileApi } from '../../api/client.js'
|
import { profileApi } from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { User } from '../../types/models'
|
||||||
import './ProfilePage.css'
|
import './ProfilePage.css'
|
||||||
|
|
||||||
const EMPTY_PROFILE_FORM = {
|
interface ProfileForm {
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
currentPassword: string
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PROFILE_FORM: ProfileForm = {
|
||||||
email: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@@ -11,7 +20,7 @@ const EMPTY_PROFILE_FORM = {
|
|||||||
newPassword: '',
|
newPassword: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProfileForm(user) {
|
function createProfileForm(user: User | null): ProfileForm {
|
||||||
return {
|
return {
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
firstName: user?.firstName ?? '',
|
firstName: user?.firstName ?? '',
|
||||||
@@ -27,7 +36,7 @@ function ProfilePage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [profileForm, setProfileForm] = useState(() => createProfileForm(user))
|
const [profileForm, setProfileForm] = useState<ProfileForm>(() => createProfileForm(user))
|
||||||
|
|
||||||
const roles = Array.isArray(user?.roles) ? user.roles : []
|
const roles = Array.isArray(user?.roles) ? user.roles : []
|
||||||
|
|
||||||
@@ -64,7 +73,7 @@ function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProfileSubmit(event) {
|
async function handleProfileSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const email = profileForm.email.trim()
|
const email = profileForm.email.trim()
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import ItemCard from '../../components/ItemCard/ItemCard.jsx'
|
import ItemCard from '../../components/ItemCard/ItemCard'
|
||||||
import {
|
import {
|
||||||
inventoryApi,
|
inventoryApi,
|
||||||
locationsApi,
|
locationsApi,
|
||||||
searchApi,
|
searchApi,
|
||||||
} from '../../api/client.js'
|
} from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import {
|
import {
|
||||||
filterInventoryItems,
|
filterInventoryItems,
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatDate,
|
formatDate,
|
||||||
getExpiryStatus,
|
getExpiryStatus,
|
||||||
} from '../../utils/searchUtils.js'
|
} from '../../utils/searchUtils'
|
||||||
import { getPaginatedResults } from '../../utils/paginationUtils.js'
|
import { getPaginatedResults } from '../../utils/paginationUtils'
|
||||||
|
import type { InventoryItem, Location } from '../../types/models'
|
||||||
import './SearchPage.css'
|
import './SearchPage.css'
|
||||||
|
|
||||||
function ExpiryBadge({ expiryDate }) {
|
interface ExpiryBadgeProps {
|
||||||
|
expiryDate?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpiryBadge({ expiryDate }: ExpiryBadgeProps) {
|
||||||
const status = getExpiryStatus(expiryDate)
|
const status = getExpiryStatus(expiryDate)
|
||||||
const badgeClass = status.status === 'Expired'
|
const badgeClass = status.status === 'Expired'
|
||||||
? 'expiry-expired'
|
? 'expiry-expired'
|
||||||
@@ -40,9 +45,9 @@ function SearchPage() {
|
|||||||
const [maxExpiryDate, setMaxExpiryDate] = useState('')
|
const [maxExpiryDate, setMaxExpiryDate] = useState('')
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(15)
|
const [pageSize, setPageSize] = useState(15)
|
||||||
const [locations, setLocations] = useState([])
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
const [matchingLocations, setMatchingLocations] = useState([])
|
const [matchingLocations, setMatchingLocations] = useState<Location[]>([])
|
||||||
const [results, setResults] = useState([])
|
const [results, setResults] = useState<InventoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
householdsApi,
|
householdsApi,
|
||||||
inventoryApi,
|
inventoryApi,
|
||||||
shoppingListsApi,
|
shoppingListsApi,
|
||||||
} from '../../api/client.js'
|
} from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { formatAmount, formatDate } from '../../utils/searchUtils.js'
|
import { formatAmount, formatDate } from '../../utils/searchUtils'
|
||||||
|
import type {
|
||||||
|
Household,
|
||||||
|
InventoryItem,
|
||||||
|
ShoppingList,
|
||||||
|
ShoppingListForm,
|
||||||
|
ShoppingListItemForm,
|
||||||
|
ShoppingListItemPayload,
|
||||||
|
} from '../../types/models'
|
||||||
import '../PlanningPage.css'
|
import '../PlanningPage.css'
|
||||||
|
|
||||||
function createShoppingListItemForm() {
|
function createShoppingListItemForm(): ShoppingListItemForm {
|
||||||
return {
|
return {
|
||||||
inventoryItemId: '',
|
inventoryItemId: '',
|
||||||
amountRequired: '',
|
amountRequired: '',
|
||||||
@@ -17,7 +25,7 @@ function createShoppingListItemForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createShoppingListForm(householdId = '') {
|
function createShoppingListForm(householdId = ''): ShoppingListForm {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
householdId,
|
householdId,
|
||||||
@@ -25,7 +33,7 @@ function createShoppingListForm(householdId = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapShoppingListToForm(shoppingList) {
|
function mapShoppingListToForm(shoppingList: ShoppingList | null): ShoppingListForm {
|
||||||
return {
|
return {
|
||||||
name: shoppingList?.name ?? '',
|
name: shoppingList?.name ?? '',
|
||||||
householdId: shoppingList?.householdId ?? '',
|
householdId: shoppingList?.householdId ?? '',
|
||||||
@@ -40,11 +48,11 @@ function mapShoppingListToForm(shoppingList) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHouseholdName(households, householdId) {
|
function resolveHouseholdName(households: Household[], householdId: string): string {
|
||||||
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
return households.find(household => household.id === householdId)?.name ?? 'Unknown household'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeShoppingListItems(items) {
|
function normalizeShoppingListItems(items: ShoppingListItemForm[]): ShoppingListItemPayload[] {
|
||||||
const normalizedItems = items
|
const normalizedItems = items
|
||||||
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
|
.filter(item => item.inventoryItemId || item.amountRequired !== '' || item.amountType.trim() || item.isPurchased)
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
@@ -85,12 +93,12 @@ function ShoppingListsPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [households, setHouseholds] = useState([])
|
const [households, setHouseholds] = useState<Household[]>([])
|
||||||
const [inventoryItems, setInventoryItems] = useState([])
|
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([])
|
||||||
const [shoppingLists, setShoppingLists] = useState([])
|
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([])
|
||||||
const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
|
const [selectedShoppingListId, setSelectedShoppingListId] = useState('')
|
||||||
const [editingShoppingListId, setEditingShoppingListId] = useState('')
|
const [editingShoppingListId, setEditingShoppingListId] = useState('')
|
||||||
const [shoppingListForm, setShoppingListForm] = useState(createShoppingListForm())
|
const [shoppingListForm, setShoppingListForm] = useState<ShoppingListForm>(createShoppingListForm())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -157,7 +165,7 @@ function ShoppingListsPage() {
|
|||||||
}))
|
}))
|
||||||
}, [editingShoppingListId, households, shoppingListForm.householdId])
|
}, [editingShoppingListId, households, shoppingListForm.householdId])
|
||||||
|
|
||||||
async function loadPageData(preferredSelectionId = '') {
|
async function loadPageData(preferredSelectionId = ''): Promise<void> {
|
||||||
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
|
const [nextHouseholdsResponse, nextInventoryResponse, nextShoppingListsResponse] = await Promise.all([
|
||||||
householdsApi.getHouseholds(),
|
householdsApi.getHouseholds(),
|
||||||
inventoryApi.getInventoryItems(),
|
inventoryApi.getInventoryItems(),
|
||||||
@@ -192,7 +200,7 @@ function ShoppingListsPage() {
|
|||||||
setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
|
setShoppingListForm(createShoppingListForm(households[0]?.id ?? ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateItemRow(index, updates) {
|
function updateItemRow(index: number, updates: Partial<ShoppingListItemForm>) {
|
||||||
setShoppingListForm(currentForm => ({
|
setShoppingListForm(currentForm => ({
|
||||||
...currentForm,
|
...currentForm,
|
||||||
items: currentForm.items.map((item, itemIndex) => (
|
items: currentForm.items.map((item, itemIndex) => (
|
||||||
@@ -210,14 +218,14 @@ function ShoppingListsPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItemRow(index) {
|
function removeItemRow(index: number) {
|
||||||
setShoppingListForm(currentForm => ({
|
setShoppingListForm(currentForm => ({
|
||||||
...currentForm,
|
...currentForm,
|
||||||
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
items: currentForm.items.filter((_, itemIndex) => itemIndex !== index),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditShoppingList(shoppingListId) {
|
async function handleEditShoppingList(shoppingListId: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
@@ -234,7 +242,7 @@ function ShoppingListsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleShoppingListSubmit(event) {
|
async function handleShoppingListSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const name = shoppingListForm.name.trim()
|
const name = shoppingListForm.name.trim()
|
||||||
@@ -287,7 +295,7 @@ function ShoppingListsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteShoppingList(shoppingListId) {
|
async function handleDeleteShoppingList(shoppingListId: string) {
|
||||||
const confirmed = window.confirm('Delete this shopping list?')
|
const confirmed = window.confirm('Delete this shopping list?')
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -504,7 +512,7 @@ function ShoppingListsPage() {
|
|||||||
{(shoppingList.items ?? []).length === 0 ? (
|
{(shoppingList.items ?? []).length === 0 ? (
|
||||||
<div className="entity-meta">No items added yet.</div>
|
<div className="entity-meta">No items added yet.</div>
|
||||||
) : (
|
) : (
|
||||||
shoppingList.items.map(item => (
|
(shoppingList.items ?? []).map(item => (
|
||||||
<div className="planning-summary-row" key={item.inventoryItemId}>
|
<div className="planning-summary-row" key={item.inventoryItemId}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
<strong>{item.inventoryItemName || 'Unnamed item'}</strong>
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { usersApi } from '../../api/client.js'
|
import { usersApi } from '../../api/client'
|
||||||
import { useAuth } from '../../context/AuthContext.jsx'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { User } from '../../types/models'
|
||||||
import './UsersPage.css'
|
import './UsersPage.css'
|
||||||
|
|
||||||
const EMPTY_USER_FORM = {
|
interface UserEditForm {
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
password: string
|
||||||
|
rolesInput: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_USER_FORM: UserEditForm = {
|
||||||
email: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
@@ -11,7 +20,7 @@ const EMPTY_USER_FORM = {
|
|||||||
rolesInput: '',
|
rolesInput: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUserName(user) {
|
function formatUserName(user: Pick<User, 'email' | 'firstName' | 'lastName'>): string {
|
||||||
const fullName = [user.firstName, user.lastName]
|
const fullName = [user.firstName, user.lastName]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -19,7 +28,7 @@ function formatUserName(user) {
|
|||||||
return fullName || user.email || 'Unnamed user'
|
return fullName || user.email || 'Unnamed user'
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapUserToForm(user) {
|
function mapUserToForm(user: User | null): UserEditForm {
|
||||||
return {
|
return {
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
firstName: user?.firstName ?? '',
|
firstName: user?.firstName ?? '',
|
||||||
@@ -29,7 +38,7 @@ function mapUserToForm(user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRoles(value) {
|
function parseRoles(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(role => role.trim())
|
.map(role => role.trim())
|
||||||
@@ -38,16 +47,16 @@ function parseRoles(value) {
|
|||||||
|
|
||||||
function UsersPage() {
|
function UsersPage() {
|
||||||
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
|
const { isAuthenticated, isSiteAdmin, refreshProfile, user } = useAuth()
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState<User[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const [statusMessage, setStatusMessage] = useState('')
|
const [statusMessage, setStatusMessage] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState('')
|
const [selectedUserId, setSelectedUserId] = useState('')
|
||||||
const [editingUserId, setEditingUserId] = useState('')
|
const [editingUserId, setEditingUserId] = useState('')
|
||||||
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
const [userForm, setUserForm] = useState<UserEditForm>(EMPTY_USER_FORM)
|
||||||
|
|
||||||
async function loadUsers(preferredSelectionId = '') {
|
async function loadUsers(preferredSelectionId = ''): Promise<void> {
|
||||||
const response = await usersApi.getUsers()
|
const response = await usersApi.getUsers()
|
||||||
const nextUsers = Array.isArray(response) ? response : []
|
const nextUsers = Array.isArray(response) ? response : []
|
||||||
|
|
||||||
@@ -130,7 +139,7 @@ function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditUser(userId) {
|
async function handleEditUser(userId: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
setStatusMessage('')
|
setStatusMessage('')
|
||||||
@@ -147,7 +156,7 @@ function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserSubmit(event) {
|
async function handleUserSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!editingUserId) {
|
if (!editingUserId) {
|
||||||
229
src/types/models.ts
Normal file
229
src/types/models.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
export type EntityId = string
|
||||||
|
|
||||||
|
export type ApiPayload = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: EntityId
|
||||||
|
email: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
roles?: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
user: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterPayload extends AuthCredentials {
|
||||||
|
confirmPassword: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse extends AuthSession {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileUpdatePayload {
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
currentPassword?: string
|
||||||
|
newPassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: EntityId
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationHistoryEntry {
|
||||||
|
id: EntityId
|
||||||
|
action: string
|
||||||
|
changedAt?: string | null
|
||||||
|
changedByEmail?: string | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: EntityId
|
||||||
|
name: string
|
||||||
|
barcode?: string | null
|
||||||
|
expiryDate?: string | null
|
||||||
|
useByDate?: string | null
|
||||||
|
amount?: number | null
|
||||||
|
amountType?: string | null
|
||||||
|
locationId?: EntityId | null
|
||||||
|
location?: Location | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItemForm {
|
||||||
|
name: string
|
||||||
|
barcode: string
|
||||||
|
expiryDate: string
|
||||||
|
useByDate: string
|
||||||
|
amount: string
|
||||||
|
amountType: string
|
||||||
|
locationId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItemPayload extends ApiPayload {
|
||||||
|
name?: string
|
||||||
|
barcode?: string
|
||||||
|
expiryDate?: string
|
||||||
|
useByDate?: string
|
||||||
|
amount?: number
|
||||||
|
amountType?: string
|
||||||
|
locationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationPayload extends ApiPayload {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdMember {
|
||||||
|
userId: EntityId
|
||||||
|
email: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
isHouseholdAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Household {
|
||||||
|
id: EntityId
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
adminEmail?: string | null
|
||||||
|
isCurrentUserHouseholdAdmin?: boolean
|
||||||
|
members?: HouseholdMember[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdHistoryEntry {
|
||||||
|
id: EntityId
|
||||||
|
action: string
|
||||||
|
changedAt?: string | null
|
||||||
|
changedByEmail?: string | null
|
||||||
|
description?: string | null
|
||||||
|
affectedUserEmail?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdPayload extends ApiPayload {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdInvitePayload extends ApiPayload {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListItemForm {
|
||||||
|
inventoryItemId: string
|
||||||
|
amountRequired: string
|
||||||
|
amountType: string
|
||||||
|
isPurchased: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListForm {
|
||||||
|
name: string
|
||||||
|
householdId: string
|
||||||
|
items: ShoppingListItemForm[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListItemPayload extends ApiPayload {
|
||||||
|
inventoryItemId: string
|
||||||
|
amountRequired: number
|
||||||
|
amountType: string
|
||||||
|
isPurchased: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListPayload extends ApiPayload {
|
||||||
|
name: string
|
||||||
|
householdId?: string
|
||||||
|
items: ShoppingListItemPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingListItem {
|
||||||
|
inventoryItemId: EntityId
|
||||||
|
inventoryItemName?: string | null
|
||||||
|
inventoryItemBarcode?: string | null
|
||||||
|
amountRequired: number
|
||||||
|
amountType: string
|
||||||
|
isPurchased: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingList {
|
||||||
|
id: EntityId
|
||||||
|
name: string
|
||||||
|
householdId: EntityId
|
||||||
|
createdAt?: string | null
|
||||||
|
createdByEmail?: string | null
|
||||||
|
items?: ShoppingListItem[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlannerItemForm {
|
||||||
|
inventoryItemId: string
|
||||||
|
amountRequired: string
|
||||||
|
amountType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlannerForm {
|
||||||
|
name: string
|
||||||
|
householdId: string
|
||||||
|
plannedDate: string
|
||||||
|
plannedTime: string
|
||||||
|
items: MealPlannerItemForm[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlannerItemPayload extends ApiPayload {
|
||||||
|
inventoryItemId: string
|
||||||
|
amountRequired: number
|
||||||
|
amountType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlannerPayload extends ApiPayload {
|
||||||
|
name: string
|
||||||
|
householdId?: string
|
||||||
|
plannedDate: string
|
||||||
|
plannedTime: string
|
||||||
|
items: MealPlannerItemPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlannerItem {
|
||||||
|
inventoryItemId: EntityId
|
||||||
|
inventoryItemName?: string | null
|
||||||
|
inventoryItemBarcode?: string | null
|
||||||
|
amountRequired: number
|
||||||
|
amountType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MealPlanner {
|
||||||
|
id: EntityId
|
||||||
|
name: string
|
||||||
|
householdId: EntityId
|
||||||
|
plannedDate?: string | null
|
||||||
|
plannedTime?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
createdByEmail?: string | null
|
||||||
|
items?: MealPlannerItem[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoInventoryItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
location: string
|
||||||
|
quantity: number
|
||||||
|
unit: string
|
||||||
|
expiryDate: string
|
||||||
|
img: string
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { toDateInputValue } from './searchUtils.js'
|
import { toDateInputValue } from './searchUtils'
|
||||||
|
import type { InventoryItem, InventoryItemForm, InventoryItemPayload } from '../types/models'
|
||||||
|
|
||||||
export const EMPTY_ITEM_FORM = {
|
export const EMPTY_ITEM_FORM: InventoryItemForm = {
|
||||||
name: '',
|
name: '',
|
||||||
barcode: '',
|
barcode: '',
|
||||||
expiryDate: '',
|
expiryDate: '',
|
||||||
@@ -10,19 +11,19 @@ export const EMPTY_ITEM_FORM = {
|
|||||||
locationId: '',
|
locationId: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBarcode(value) {
|
export function normalizeBarcode(value: unknown): string {
|
||||||
return String(value ?? '').trim()
|
return String(value ?? '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createItemForm(barcode = '') {
|
export function createItemForm(barcode = ''): InventoryItemForm {
|
||||||
return {
|
return {
|
||||||
...EMPTY_ITEM_FORM,
|
...EMPTY_ITEM_FORM,
|
||||||
barcode: normalizeBarcode(barcode),
|
barcode: normalizeBarcode(barcode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInventoryPayload(form, includeBlankText = false) {
|
export function buildInventoryPayload(form: InventoryItemForm, includeBlankText = false): InventoryItemPayload {
|
||||||
const payload = {}
|
const payload: InventoryItemPayload = {}
|
||||||
const name = form.name.trim()
|
const name = form.name.trim()
|
||||||
|
|
||||||
const barcode = normalizeBarcode(form.barcode)
|
const barcode = normalizeBarcode(form.barcode)
|
||||||
@@ -40,7 +41,7 @@ export function buildInventoryPayload(form, includeBlankText = false) {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapItemToForm(item) {
|
export function mapItemToForm(item: InventoryItem): InventoryItemForm {
|
||||||
return {
|
return {
|
||||||
name: item.name ?? '',
|
name: item.name ?? '',
|
||||||
barcode: item.barcode ?? '',
|
barcode: item.barcode ?? '',
|
||||||
@@ -52,7 +53,7 @@ export function mapItemToForm(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findItemsByBarcode(items, barcode) {
|
export function findItemsByBarcode(items: InventoryItem[], barcode: string): InventoryItem[] {
|
||||||
const normalizedBarcode = normalizeBarcode(barcode)
|
const normalizedBarcode = normalizeBarcode(barcode)
|
||||||
|
|
||||||
if (!normalizedBarcode) {
|
if (!normalizedBarcode) {
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
export function getPaginatedResults(filteredItems, currentPage, pageSize) {
|
export interface PaginatedResults<T> {
|
||||||
|
items: T[]
|
||||||
|
currentPage: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
totalItems: number
|
||||||
|
startIndex: number
|
||||||
|
endIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaginatedResults<T>(filteredItems: T[], currentPage: number, pageSize: number): PaginatedResults<T> {
|
||||||
const totalItems = filteredItems.length
|
const totalItems = filteredItems.length
|
||||||
const totalPages = Math.ceil(totalItems / pageSize) || 1
|
const totalPages = Math.ceil(totalItems / pageSize) || 1
|
||||||
|
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
export function formatDate(dateStr) {
|
import type { InventoryItem } from '../types/models'
|
||||||
|
|
||||||
|
export interface ExpiryStatus {
|
||||||
|
status: 'Unknown' | 'Expired' | 'Today' | 'Soon' | 'Fresh'
|
||||||
|
color: string
|
||||||
|
days?: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryFilters {
|
||||||
|
locationId?: string
|
||||||
|
minAmount?: number
|
||||||
|
maxAmount?: number
|
||||||
|
minExpiryDate?: string
|
||||||
|
maxExpiryDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
|
|
||||||
const normalizedDate = toDateInputValue(dateStr)
|
const normalizedDate = toDateInputValue(dateStr)
|
||||||
@@ -9,7 +26,7 @@ export function formatDate(dateStr) {
|
|||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(timeStr) {
|
export function formatTime(timeStr: string | null | undefined): string {
|
||||||
const normalizedTime = toTimeInputValue(timeStr)
|
const normalizedTime = toTimeInputValue(timeStr)
|
||||||
|
|
||||||
if (!normalizedTime) return ''
|
if (!normalizedTime) return ''
|
||||||
@@ -27,17 +44,17 @@ export function formatTime(timeStr) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toDateInputValue(dateStr) {
|
export function toDateInputValue(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
return String(dateStr).slice(0, 10)
|
return String(dateStr).slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toTimeInputValue(timeStr) {
|
export function toTimeInputValue(timeStr: string | null | undefined): string {
|
||||||
if (!timeStr) return ''
|
if (!timeStr) return ''
|
||||||
return String(timeStr).slice(0, 5)
|
return String(timeStr).slice(0, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExpiryStatus(expiryDate) {
|
export function getExpiryStatus(expiryDate: string | null | undefined): ExpiryStatus {
|
||||||
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }
|
if (!expiryDate) return { status: 'Unknown', color: '#999', text: '' }
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -50,7 +67,7 @@ export function getExpiryStatus(expiryDate) {
|
|||||||
return { status: 'Unknown', color: '#999', text: '' }
|
return { status: 'Unknown', color: '#999', text: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const daysUntilExpiry = Math.floor((expiry - today) / (1000 * 60 * 60 * 24))
|
const daysUntilExpiry = Math.floor((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
if (daysUntilExpiry < 0) {
|
if (daysUntilExpiry < 0) {
|
||||||
return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` }
|
return { status: 'Expired', color: '#f44336', days: daysUntilExpiry, text: `Expired ${Math.abs(daysUntilExpiry)} days ago` }
|
||||||
@@ -63,14 +80,17 @@ export function getExpiryStatus(expiryDate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(amount, amountType = '') {
|
export function formatAmount(
|
||||||
|
amount: number | string | null | undefined,
|
||||||
|
amountType: string | null | undefined = '',
|
||||||
|
): string {
|
||||||
if (amount == null || amount === '') return 'Amount not set'
|
if (amount == null || amount === '') return 'Amount not set'
|
||||||
|
|
||||||
const unit = amountType?.trim() ?? ''
|
const unit = amountType?.trim() ?? ''
|
||||||
return unit ? `${amount} ${unit}` : String(amount)
|
return unit ? `${amount} ${unit}` : String(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterInventoryItems(items, filters = {}) {
|
export function filterInventoryItems(items: InventoryItem[], filters: InventoryFilters = {}): InventoryItem[] {
|
||||||
const {
|
const {
|
||||||
locationId = '',
|
locationId = '',
|
||||||
minAmount = 0,
|
minAmount = 0,
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"useUnknownInCatchVariables": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user